Kinetic Typography: How to Create Stunning Animated Text With Code
Kinetic typography is text that moves. Not scrolling marquees or blinking cursors—text that behaves as if it has weight, elasticity, and intention. Letters that fall under gravity and bounce on a baseline. Words that dissolve into particles and reassemble somewhere else. Sentences that ripple like water when you drag your mouse across them.
The technique started in film title sequences. Saul Bass set the template in the 1950s with his opening credits for Hitchcock—simple type, bold movement, emotional impact. Then motion graphics software made it accessible to designers, and the explosion of lyric videos on YouTube in the 2010s turned kinetic typography into a genre of its own.
For creative coders, kinetic typography sits at a sweet intersection. The Canvas API gives you everything you need: measureText for layout, fillText for rendering, and requestAnimationFrame for smooth motion. A few hundred lines of JavaScript can produce text animation that feels polished and alive.
In this guide we build eight kinetic typography programs from scratch. Every example is self-contained, runs on a plain HTML Canvas with no libraries, and stays under 50KB. For related animation techniques, see the SVG animation guide, the ASCII art guide, or the drawing with code guide.
Setting up
Every example uses this minimal HTML setup:
<canvas id="c" width="800" height="600"></canvas>
<script>
const c = document.getElementById('c');
const ctx = c.getContext('2d');
const W = c.width, H = c.height;
// ... example code goes here ...
</script>
Paste each example into the script section. All code is vanilla JavaScript with the Canvas 2D API.
1. Typewriter reveal
The simplest kinetic text effect: characters appear one by one, as if someone is typing them in real time. A blinking cursor follows the current position. Natural typing has uneven timing, so we add random delays between characters and longer pauses at punctuation.
const text = "Every branch is a world. Every world is alive.";
const fontSize = 42;
ctx.font = `${fontSize}px Georgia, serif`;
ctx.textBaseline = 'middle';
let charIndex = 0;
let lastTime = 0;
let nextDelay = 0;
const baseDelay = 60;
function getDelay(ch) {
if (ch === '.' || ch === '!' || ch === '?') return baseDelay * 8;
if (ch === ',') return baseDelay * 4;
if (ch === ' ') return baseDelay * 1.5;
return baseDelay + Math.random() * baseDelay * 0.8;
}
function draw(t) {
if (t - lastTime > nextDelay && charIndex < text.length) {
nextDelay = getDelay(text[charIndex]);
lastTime = t;
charIndex++;
}
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, W, H);
const shown = text.slice(0, charIndex);
const x = 60, y = H / 2;
ctx.fillStyle = '#e8e0d4';
ctx.font = `${fontSize}px Georgia, serif`;
ctx.fillText(shown, x, y);
const textW = ctx.measureText(shown).width;
if (Math.floor(t / 500) % 2 === 0 || charIndex < text.length) {
ctx.fillStyle = '#f0c040';
ctx.fillRect(x + textW + 4, y - fontSize * 0.4, 3, fontSize * 0.8);
}
if (charIndex >= text.length && t - lastTime > 3000) {
charIndex = 0; lastTime = t;
}
requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
The variable delay is what makes it feel human. Fixed intervals produce a mechanical rhythm that reads as robotic. The golden-yellow cursor adds a visual anchor that guides the eye.
2. Wave text
Each character oscillates up and down on a sine wave, offset by its position in the string. The result is text that ripples like a flag in the wind—simple maths, high visual impact.
const text = "KINETIC TYPOGRAPHY";
const fontSize = 64;
ctx.textBaseline = 'middle';
ctx.textAlign = 'center';
function draw(t) {
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, W, H);
ctx.font = `bold ${fontSize}px Helvetica, Arial, sans-serif`;
const totalW = ctx.measureText(text).width;
let x = (W - totalW) / 2;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
const charW = ctx.measureText(ch).width;
const phase = i * 0.35 - t * 0.003;
const yOff = Math.sin(phase) * 25;
const scale = 1 + Math.sin(phase) * 0.1;
const hue = (i * 18 + t * 0.05) % 360;
ctx.save();
ctx.translate(x + charW / 2, H / 2 + yOff);
ctx.scale(scale, scale);
ctx.fillStyle = `hsl(${hue}, 70%, 65%)`;
ctx.fillText(ch, 0, 0);
ctx.restore();
x += charW;
}
requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
The phase offset (i * 0.35) controls wave density. Lower values give slow, rolling waves; higher values create tight ripples. The subtle scale oscillation makes characters breathe.
3. Spring-physics letters
Each character has a target position connected by a spring. When you move the mouse, nearby letters get pushed away and then spring back. Letters start scattered and settle into position, creating a satisfying entrance animation.
const text = "TOUCH THE LETTERS";
const fontSize = 56;
ctx.font = `bold ${fontSize}px Helvetica, Arial, sans-serif`;
ctx.textBaseline = 'middle';
const letters = [];
let totalW = 0;
for (const ch of text) {
const w = ctx.measureText(ch).width;
letters.push({ ch, w, tx:0, ty:0, x:0, y:0, vx:0, vy:0 });
totalW += w;
}
let cx = (W - totalW) / 2;
for (const l of letters) {
l.tx = cx + l.w/2; l.ty = H/2;
l.x = l.tx + (Math.random()-0.5)*400;
l.y = l.ty + (Math.random()-0.5)*300;
cx += l.w;
}
let mx = W/2, my = H/2;
c.addEventListener('mousemove', e => {
const r = c.getBoundingClientRect();
mx = (e.clientX - r.left) * (W / r.width);
my = (e.clientY - r.top) * (H / r.height);
});
function draw() {
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, W, H);
ctx.font = `bold ${fontSize}px Helvetica, Arial, sans-serif`;
for (const l of letters) {
l.vx += (l.tx - l.x) * 0.04;
l.vy += (l.ty - l.y) * 0.04;
const mdx = l.x - mx, mdy = l.y - my;
const md = Math.hypot(mdx, mdy);
if (md < 120 && md > 0) {
const force = (1 - md/120) * 8;
l.vx += (mdx/md)*force; l.vy += (mdy/md)*force;
}
l.vx *= 0.88; l.vy *= 0.88;
l.x += l.vx; l.y += l.vy;
const speed = Math.hypot(l.vx, l.vy);
ctx.fillStyle = `hsl(${Math.min(speed*15,60)}, 75%, ${65+Math.min(speed*3,25)}%)`;
ctx.textAlign = 'center';
ctx.fillText(l.ch, l.x, l.y);
}
requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
The mouse repulsion creates an interactive playground. Faster-moving letters glow warmer, giving visual feedback on motion energy.
4. Particle text dissolve
Render text to an offscreen canvas, read the pixel data, and create a particle at every filled pixel. On click, particles scatter outward; after a delay, they spring back and reassemble into the original text.
const text = "DISSOLVE";
const fontSize = 120;
const off = document.createElement('canvas');
off.width = W; off.height = H;
const octx = off.getContext('2d');
octx.fillStyle = '#fff';
octx.font = `bold ${fontSize}px Helvetica, Arial, sans-serif`;
octx.textAlign = 'center'; octx.textBaseline = 'middle';
octx.fillText(text, W/2, H/2);
const imgData = octx.getImageData(0, 0, W, H).data;
const particles = [];
const gap = 4;
for (let y = 0; y < H; y += gap)
for (let x = 0; x < W; x += gap)
if (imgData[(y*W+x)*4+3] > 128)
particles.push({ tx:x, ty:y, x, y, vx:0, vy:0,
size: 2+Math.random(), hue: Math.random()*40+20 });
let dissolved = false, timer = 0;
c.onclick = () => {
if (!dissolved) {
for (const p of particles) {
const a = Math.random()*Math.PI*2, s = 3+Math.random()*8;
p.vx = Math.cos(a)*s; p.vy = Math.sin(a)*s;
}
dissolved = true; timer = 0;
}
};
function draw(t) {
ctx.fillStyle = 'rgba(10,10,26,0.15)';
ctx.fillRect(0, 0, W, H);
if (dissolved) {
timer++;
const ret = timer > 90;
for (const p of particles) {
if (ret) {
p.vx += (p.tx-p.x)*0.03; p.vy += (p.ty-p.y)*0.03;
p.vx *= 0.92; p.vy *= 0.92;
} else { p.vy += 0.05; p.vx *= 0.99; p.vy *= 0.99; }
p.x += p.vx; p.y += p.vy;
const speed = Math.hypot(p.vx, p.vy);
ctx.fillStyle = `hsl(${p.hue+speed*10}, 80%, ${55+speed*5}%)`;
ctx.fillRect(p.x, p.y, p.size, p.size);
}
if (ret && particles.every(p => Math.hypot(p.x-p.tx,p.y-p.ty)<2)) dissolved = false;
} else {
for (const p of particles) {
ctx.fillStyle = `hsl(${p.hue}, 60%, 65%)`;
ctx.fillRect(p.x, p.y, p.size, p.size);
}
}
requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
The gap variable controls density: smaller gaps mean more particles. A gap of 3–4 hits the sweet spot between visual fidelity and performance.
5. Text along a curved path
Place each character along a lemniscate (figure-eight). The character rotates to follow the curve tangent, so it reads naturally along the path.
const text = "KINETIC TYPOGRAPHY IS TEXT THAT MOVES AND BREATHES ";
const fontSize = 22;
function draw(t) {
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, W, H);
ctx.font = `bold ${fontSize}px Helvetica, Arial, sans-serif`;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
const offset = t * 0.0004;
const charSpacing = fontSize * 0.7;
let dist = 0;
for (let i = 0; i < text.length; i++) {
const param = offset + dist / 800 * Math.PI * 2;
const a = 250;
const cos = Math.cos(param), sin = Math.sin(param);
const denom = 1 + sin * sin;
const px = W/2 + a*cos/denom, py = H/2 + a*sin*cos/denom;
const dt = 0.001;
const cos2 = Math.cos(param+dt), sin2 = Math.sin(param+dt);
const d2 = 1 + sin2*sin2;
const px2 = W/2+a*cos2/d2, py2 = H/2+a*sin2*cos2/d2;
const angle = Math.atan2(py2-py, px2-px);
const hue = (i*8 + t*0.03) % 360;
ctx.save();
ctx.translate(px, py); ctx.rotate(angle);
ctx.fillStyle = `hsl(${hue}, 65%, 65%)`;
ctx.fillText(text[i], 0, 0);
ctx.restore();
dist += charSpacing;
}
requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
The lemniscate keeps all characters visible on screen, unlike a circle where half the text would be upside down.
6. Gravity-drop letters
Letters fall under gravity one by one, bouncing off the baseline. The staggered drop times create a cascade that reads left to right.
const text = "GRAVITY DROPS";
const fontSize = 72;
ctx.font = `bold ${fontSize}px Helvetica, Arial, sans-serif`;
ctx.textBaseline = 'alphabetic';
const baseY = H * 0.75;
const letters = [];
let totalW = 0;
for (const ch of text) totalW += ctx.measureText(ch).width;
let x = (W - totalW) / 2;
for (let i = 0; i < text.length; i++) {
const ch = text[i], w = ctx.measureText(ch).width;
letters.push({ ch, x:x+w/2, w, y:-50-Math.random()*200,
vy:0, landed:false, dropTime:i*120,
weight: 0.8+(w/fontSize)*0.4,
rotation:0, vr:(Math.random()-0.5)*0.1, hue:i*25+10 });
x += w;
}
const startTime = performance.now();
function draw(t) {
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, W, H);
ctx.strokeStyle = 'rgba(255,255,255,0.08)';
ctx.beginPath(); ctx.moveTo(40,baseY); ctx.lineTo(W-40,baseY); ctx.stroke();
const elapsed = t - startTime;
ctx.font = `bold ${fontSize}px Helvetica, Arial, sans-serif`;
ctx.textAlign = 'center'; ctx.textBaseline = 'alphabetic';
for (const l of letters) {
if (elapsed < l.dropTime) continue;
if (!l.landed || Math.abs(l.vy) > 0.1) {
l.vy += 0.4 * l.weight; l.y += l.vy; l.rotation += l.vr;
if (l.y >= baseY) {
l.y = baseY; l.vy *= -0.6; l.vr *= 0.5;
if (Math.abs(l.vy) < 1) { l.vy=0; l.vr=0; l.rotation=0; l.landed=true; }
}
}
ctx.save();
ctx.translate(l.x, l.y); ctx.rotate(l.rotation);
ctx.fillStyle = `hsl(${l.hue}, 70%, ${l.landed ? 65 : 65+Math.min(Math.abs(l.vy)*2,25)}%)`;
ctx.fillText(l.ch, 0, 0);
ctx.restore();
}
requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
The rotation during fall adds realism. Heavier-looking letters (wider characters) fall faster, creating a subtle visual hierarchy.
7. Morphing text outlines
Render text offscreen, extract edge pixels, then interpolate between two different words. Points are sorted by angle from centre for smooth transitions.
const words = ["CREATE", "EVOLVE", "DREAM", "BUILD", "GROW"];
const fontSize = 140;
let wordIdx = 0, morphT = 0;
function getTextPoints(word) {
const off = document.createElement('canvas');
off.width = W; off.height = H;
const o = off.getContext('2d');
o.font = `bold ${fontSize}px Helvetica, Arial, sans-serif`;
o.textAlign = 'center'; o.textBaseline = 'middle';
o.fillStyle = '#fff'; o.fillText(word, W/2, H/2);
const d = o.getImageData(0, 0, W, H).data;
const pts = [], step = 3;
for (let y = 0; y < H; y += step)
for (let x = 0; x < W; x += step) {
if (d[(y*W+x)*4+3] > 128) {
let edge = false;
for (const [dx,dy] of [[-step,0],[step,0],[0,-step],[0,step]]) {
const nx=x+dx, ny=y+dy;
if (nx<0||nx>=W||ny<0||ny>=H||d[(ny*W+nx)*4+3]<128) { edge=true; break; }
}
if (edge) pts.push([x, y]);
}
}
return pts;
}
const allPoints = words.map(w => getTextPoints(w));
const maxPts = Math.max(...allPoints.map(p => p.length));
for (const pts of allPoints)
while (pts.length < maxPts) pts.push([...pts[pts.length-1]]);
for (const pts of allPoints)
pts.sort((a,b) => Math.atan2(a[1]-H/2,a[0]-W/2)-Math.atan2(b[1]-H/2,b[0]-W/2));
function draw(t) {
ctx.fillStyle = 'rgba(10,10,26,0.12)';
ctx.fillRect(0, 0, W, H);
morphT += 0.012;
if (morphT >= 1) { morphT = 0; wordIdx = (wordIdx+1) % words.length; }
const from = allPoints[wordIdx], to = allPoints[(wordIdx+1)%words.length];
const ease = morphT<0.5 ? 2*morphT*morphT : 1-2*(1-morphT)*(1-morphT);
for (let i = 0; i < from.length; i++) {
const x = from[i][0]+(to[i][0]-from[i][0])*ease;
const y = from[i][1]+(to[i][1]-from[i][1])*ease;
ctx.fillStyle = `hsl(${(i*0.3+t*0.02)%360}, 65%, 60%)`;
ctx.fillRect(x, y, 2, 2);
}
ctx.fillStyle = 'rgba(255,255,255,0.15)';
ctx.font = '16px Helvetica, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(words[wordIdx]+' → '+words[(wordIdx+1)%words.length], W/2, H-30);
requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
Sorting by angle prevents spaghetti-like crossing. The quadratic easing gives the morph a soft start and stop.
8. Generative kinetic poetry
The final example combines multiple techniques. Words appear with different kinetic behaviours—wave, pulse, drift, flicker. Click to advance to the next stanza.
const stanzas = [
["every branch", "is a world", "waiting to", "be explored"],
["code becomes", "canvas becomes", "forest becomes", "infinite"],
["plant a seed", "watch it grow", "into something", "unexpected"],
["art from math", "beauty from", "simple rules", "endlessly"]
];
let stanza = 0;
const fontSize = 48, lineH = fontSize * 1.6;
class KineticLine {
constructor(text, index, style) {
this.text = text; this.index = index; this.style = style;
this.enterT = 0;
this.baseY = H/2-(4*lineH)/2+index*lineH+lineH/2;
this.phase = Math.random()*Math.PI*2;
}
update() { this.enterT = Math.min(this.enterT+0.02, 1); }
draw(t) {
if (this.enterT <= 0) return;
ctx.save();
ctx.globalAlpha = this.enterT;
ctx.font = `${fontSize}px Georgia, serif`;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
const s = t*0.001, hue = (this.index*60+t*0.02)%360;
if (this.style === 0) {
const chars = this.text.split('');
const widths = chars.map(ch => ctx.measureText(ch).width);
let cx = W/2 - widths.reduce((a,b)=>a+b,0)/2;
for (let i = 0; i < chars.length; i++) {
const yOff = Math.sin(s*2+i*0.4+this.phase)*12*this.enterT;
ctx.fillStyle = `hsl(${(hue+i*10)%360}, 60%, 70%)`;
ctx.fillText(chars[i], cx+widths[i]/2, this.baseY+yOff);
cx += widths[i];
}
} else if (this.style === 1) {
const sc = 1+Math.sin(s*3+this.phase)*0.08*this.enterT;
ctx.translate(W/2, this.baseY); ctx.scale(sc, sc);
ctx.fillStyle = `hsl(${hue}, 60%, 70%)`;
ctx.fillText(this.text, 0, 0);
} else if (this.style === 2) {
const drift = Math.sin(s*1.5+this.phase)*30*this.enterT;
ctx.fillStyle = `hsl(${hue}, 60%, 70%)`;
ctx.fillText(this.text, W/2+drift, this.baseY);
} else {
ctx.globalAlpha = this.enterT*(0.6+Math.sin(s*5+this.phase)*0.4);
ctx.fillStyle = `hsl(${hue}, 60%, 70%)`;
ctx.fillText(this.text, W/2, this.baseY);
}
ctx.restore();
}
}
let lines = [];
function loadStanza() {
lines = stanzas[stanza].map((text,i) => new KineticLine(text,i,i%4));
}
loadStanza();
c.onclick = () => { stanza=(stanza+1)%stanzas.length; loadStanza(); };
function draw(t) {
ctx.fillStyle = 'rgba(10,10,26,0.08)';
ctx.fillRect(0, 0, W, H);
for (const line of lines) { line.update(); line.draw(t); }
ctx.globalAlpha = 0.2; ctx.fillStyle = '#fff';
ctx.font = '14px Helvetica, sans-serif'; ctx.textAlign = 'center';
ctx.fillText('click for next stanza', W/2, H-24);
ctx.globalAlpha = 1;
requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
Each line gets a different movement style, creating visual variety within a single composition. The slow trail effect gives motion a ghostly persistence that feels more poetic than a hard clear.
Tips for better kinetic typography
- Timing is everything. A letter dropping at 200ms feels different from one dropping at 400ms. Vary your timing to create rhythm and emphasis.
- Character-level animation beats word-level. Animating individual characters gives you more expressive range. The trade-off is complexity: you need per-character positioning via measureText.
- Use easing, not linear motion. Text that eases in and out of position feels intentional. Linear movement feels mechanical.
- Colour encodes motion. Make faster-moving text brighter or warmer for a secondary visual channel.
- Readable beats impressive. If nobody can read the text, the animation failed.
- The pixel-sampling trick unlocks everything. Render text offscreen, read the pixels, create particles. Once text becomes a point cloud, you can apply any physics system to it.
- Match motion to meaning. Gravity for heavy words. Floating for light ones. Shaking for urgency.
Explore more generative art on Lumitree, where every branch is a unique micro-world built from code. For more animation techniques, try the SVG animation guide, the ASCII art guide, or the drawing with code guide.