Kinetic Typography - Day 28
Advanced LevelEvery letter in the kinetic typography system is treated as an independent particle. Each letter-particle carries its own position, velocity, orbital properties, animation state, and visual styling. This allows each character to move, orbit, and transition independently while still being part of a coherent word formation.
class LetterParticle {
constructor(char, x, y, color) {
// Identity
this.char = char;
// Current position and target
this.x = x;
this.y = y;
this.targetX = x;
this.targetY = y;
// Velocity for physics-based movement
this.vx = (Math.random() - 0.5) * 8;
this.vy = (Math.random() - 0.5) * 8;
// Orbital properties
this.angle = Math.random() * Math.PI * 2;
this.orbitRadius = Math.random() * 120 + 60;
this.orbitSpeed = Math.random() * 0.04 + 0.01;
// Animation state
this.state = 'idle';
this.color = color || '#00ffcc';
this.fontSize = 24;
this.alpha = 1;
this.scale = 1;
}
}
Each letter receives randomized angle, orbitRadius, and orbitSpeed values. This prevents letters from clumping together during orbital animations. The initial velocity vx and vy values give each letter a unique explosion trajectory when transitioning out of the idle state.
// Create particles from a word string
createWordParticles(word, centerX, centerY) {
const particles = [];
const spacing = 30;
const startX = centerX - (word.length * spacing) / 2;
for (let i = 0; i < word.length; i++) {
const hue = (i / word.length) * 60 + 160;
const color = `hsl(${hue}, 100%, 70%)`;
const p = new LetterParticle(
word[i], startX + i * spacing, centerY, color
);
particles.push(p);
}
return particles;
}
Each letter's hue is derived from its index within the word. This automatically creates a rainbow gradient across the word without manually assigning colors. The hue range 160-220 maps from cyan to blue-violet, fitting the project's color palette.
The orbital physics engine governs how letter-particles move through space when they are not forming words. Each particle orbits around a center point using angular velocity, with optional wave motion and spiral decay patterns layered on top for visual complexity.
updateOrbital(centerX, centerY, dt) {
// Advance angle based on orbit speed
this.angle += this.orbitSpeed * dt;
// Circular orbit position
const orbitX = centerX + Math.cos(this.angle) * this.orbitRadius;
const orbitY = centerY + Math.sin(this.angle) * this.orbitRadius;
// Wave motion overlay (vertical wobble)
const wave = Math.sin(this.angle * 3) * 15;
orbitY += wave;
// Smooth interpolation toward orbit position
this.x += (orbitX - this.x) * 0.08;
this.y += (orbitY - this.y) * 0.08;
}
// Spiral pattern - radius shrinks over time
updateSpiral(centerX, centerY, dt) {
this.angle += this.orbitSpeed * dt * 1.5;
this.orbitRadius *= 0.998; // Gradual decay
const x = centerX + Math.cos(this.angle) * this.orbitRadius;
const y = centerY + Math.sin(this.angle) * this.orbitRadius;
this.x += (x - this.x) * 0.1;
this.y += (y - this.y) * 0.1;
}
A configurable gravity parameter pulls particles downward during the explode phase, giving the explosion a natural arc. During orbit mode, gravity is disabled so letters float freely in their circular paths.
// Apply gravity during explosion phase
if (this.state === 'exploding') {
this.vy += gravity * dt;
this.x += this.vx * dt;
this.y += this.vy * dt;
// Apply drag to slow particles over time
this.vx *= 0.99;
this.vy *= 0.99;
}
Instead of snapping particles directly to their orbit positions, we use linear interpolation (lerp) with a factor of 0.08. This creates a smooth, elastic feel as letters glide into orbit rather than teleporting.
The Web Audio API provides real-time sound synthesis without any audio files. Each letter triggers a note when activated, using an OscillatorNode for tone generation and a GainNode for volume envelope shaping. The result is a musical typewriter effect where words become melodies.
const audioCtx = new (window.AudioContext
|| window.webkitAudioContext)();
function playNote(frequency, duration) {
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
// Connect: oscillator -> gain -> output
osc.connect(gain);
gain.connect(audioCtx.destination);
// Sine wave for soft, clean tone
osc.type = 'sine';
osc.frequency.value = frequency;
// ADSR-like envelope
const now = audioCtx.currentTime;
gain.gain.setValueAtTime(0, now);
gain.gain.linearRampToValueAtTime(0.3, now + 0.02); // Attack
gain.gain.linearRampToValueAtTime(0.2, now + 0.08); // Decay
gain.gain.linearRampToValueAtTime(0, now + duration); // Release
osc.start(now);
osc.stop(now + duration);
}
The Web Audio API offers four built-in waveforms: sine (pure, mellow), square (retro, buzzy), sawtooth (bright, rich), and triangle (soft, warm). The kinetic typography project uses sine waves for a clean, musical box quality that complements the visual motion without being harsh.
// Frequency from MIDI note number
function midiToFreq(note) {
return 440 * Math.pow(2, (note - 69) / 12);
}
// Example: Middle C (C4) = MIDI 60
const middleC = midiToFreq(60); // 261.63 Hz
Modern browsers require user interaction before allowing audio playback. Always call audioCtx.resume() inside a click or touch event handler. Without this, the oscillator will be created but produce no sound, a common source of bugs in Web Audio projects.
The pentatonic scale contains five notes per octave instead of the usual seven. Its special property is that any combination of these notes sounds harmonious together. This makes it perfect for generative music where random note selection must always sound pleasant.
// C major pentatonic: C4, D4, E4, G4, A4
const pentatonic = [
261.63, // C4
293.66, // D4
329.63, // E4
392.00, // G4
440.00 // A4
];
// Extended with octave above for range
const extendedScale = [
...pentatonic,
523.25, // C5
587.33, // D5
659.25 // E5
];
// Map letter index to scale note
function letterToNote(index, totalLetters) {
const scaleIndex = index % extendedScale.length;
return extendedScale[scaleIndex];
}
Each letter in a word maps to a specific note based on its index. The first letter plays C4, the second D4, and so on. When a word forms, its letters play in sequence, turning the word into a unique melody. Longer words create more complex musical phrases that ascend through the scale and wrap back around.
// Play word as ascending melody
function playWordMelody(word, tempo) {
const noteDelay = 60 / tempo; // seconds per beat
for (let i = 0; i < word.length; i++) {
const freq = letterToNote(i, word.length);
const delay = i * noteDelay * 1000;
setTimeout(() => {
playNote(freq, noteDelay * 0.8);
particles[i].scale = 1.3; // Visual pulse
}, delay);
}
}
The pentatonic scale avoids semitone intervals (half steps), which are the source of dissonance in Western music. Without semitones, any two notes played simultaneously or sequentially sound consonant. This is why pentatonic scales appear in music traditions worldwide, from Chinese folk to blues.
The animation system uses a finite state machine with four states: idle, exploding, orbiting, and forming. Each state defines how particles behave, and transitions between states are triggered by timers or user interaction. This keeps the animation logic clean and predictable.
// State machine: idle -> exploding -> orbiting -> forming -> idle
const states = {
IDLE: 'idle',
EXPLODING: 'exploding',
ORBITING: 'orbiting',
FORMING: 'forming'
};
let currentState = states.IDLE;
let stateTimer = 0;
function updateStateMachine(dt) {
stateTimer += dt;
switch (currentState) {
case states.IDLE:
// Letters sit in word formation
if (stateTimer > 2.0) {
transitionTo(states.EXPLODING);
}
break;
case states.EXPLODING:
// Letters fly apart with velocity
applyExplosionForce();
if (stateTimer > 0.8) {
transitionTo(states.ORBITING);
}
break;
case states.ORBITING:
// Letters orbit center point
updateAllOrbits();
if (stateTimer > 3.0) {
transitionTo(states.FORMING);
}
break;
case states.FORMING:
// Letters return to word positions
moveToTargets();
if (allParticlesSettled()) {
transitionTo(states.IDLE);
selectNextWord();
}
break;
}
}
The transitionTo function resets the state timer and prepares particles for their new behavior. During the explosion transition, each particle gets a random velocity. During the forming transition, target positions are calculated for the new word layout.
function transitionTo(newState) {
currentState = newState;
stateTimer = 0;
if (newState === states.EXPLODING) {
// Give each particle random outward velocity
particles.forEach(p => {
const angle = Math.random() * Math.PI * 2;
const force = Math.random() * 6 + 4;
p.vx = Math.cos(angle) * force;
p.vy = Math.sin(angle) * force;
p.state = 'exploding';
});
}
if (newState === states.FORMING) {
// Calculate target positions for new word
particles.forEach((p, i) => {
p.targetX = startX + i * spacing;
p.targetY = centerY;
p.state = 'forming';
});
}
}
Using a state timer that resets on each transition is simpler than tracking global time. Each state only needs to know how long it has been active. This pattern is widely used in game development for animation sequences, AI behaviors, and UI transitions.
The project supports both desktop and mobile interaction through unified input handling. Users can tap words to trigger animations, adjust tempo and gravity with sliders, and switch between animation modes. All control values feed directly into the physics engine parameters.
// Unified pointer handling (mouse + touch)
const canvas = document.getElementById('canvas');
canvas.addEventListener('pointerdown', (e) => {
e.preventDefault();
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Check if tap hit a letter particle
const hit = particles.find(p => {
const dx = p.x - x;
const dy = p.y - y;
return Math.sqrt(dx * dx + dy * dy) < 25;
});
if (hit) {
// Trigger single letter animation + sound
activateLetter(hit);
} else {
// Trigger full word cycle
transitionTo(states.EXPLODING);
}
});
// Touch-friendly: prevent scroll on canvas
canvas.addEventListener('touchmove', (e) => {
e.preventDefault();
}, { passive: false });
Range inputs allow real-time adjustment of the physics simulation. The tempo slider controls how fast the word melody plays, while the gravity slider adjusts how quickly particles fall during the explosion phase. Mode switching changes the orbital pattern between circular, wave, and spiral.
// Tempo control: affects melody speed and animation timing
const tempoSlider = document.getElementById('tempo');
tempoSlider.addEventListener('input', (e) => {
settings.tempo = parseFloat(e.target.value);
// Update state durations based on tempo
settings.idleDuration = 120 / settings.tempo;
settings.orbitDuration = 180 / settings.tempo;
});
// Gravity control: affects explosion arcs
const gravSlider = document.getElementById('gravity');
gravSlider.addEventListener('input', (e) => {
settings.gravity = parseFloat(e.target.value);
});
// Mode switching (circular, wave, spiral)
const modeButtons = document.querySelectorAll('.mode-btn');
modeButtons.forEach(btn => {
btn.addEventListener('click', () => {
settings.orbitMode = btn.dataset.mode;
modeButtons.forEach(b =>
b.classList.remove('active')
);
btn.classList.add('active');
});
});
Using the Pointer Events API (pointerdown, pointermove, pointerup) handles both mouse and touch input with a single set of event listeners. This eliminates the need for separate mouse and touch handlers and avoids the 300ms touch delay on mobile browsers.