Live preview needs JavaScript. The code below works without any dependencies.
Prompt for your AI assistant
Paste this into Claude, Cursor or ChatGPT. The settings update as you move the controls above.
Build "Kinetic type warp" for my site using the Enrichr Library element at https://enrichr.dev/elements/kinetic-type-warp.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/kinetic-type-warp.json (markup, CSS, JavaScript, parameters).
Requirements: vanilla JavaScript and CSS, no new dependencies; keep the behaviour of the source unchanged; respect prefers-reduced-motion; work on touch screens; keep it accessible.
Settings:
- text: Kinetic type
- strength: 60
- strip: 4
- manual: 0
- color: #fbf8f1
- accent: #e6ff55
- background: #0c1e18
Verify: reduced motion shows no movement, it works at 375px wide, there are no console errors, and scrolling stays smooth.The same spec as JSON, for tools that read it directly: /elements/kinetic-type-warp.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-ktw"><span class="ef-ktw__txt"></span><canvas class="ef-ktw__c" aria-hidden="true"></canvas></div>CSS
/* Enrichr Library · https://enrichr.dev */
.ef-ktw { position: relative; width: 100%; min-height: 18rem; overflow: hidden; background: var(--ef-ktw-bg, #0c1e18); color: var(--ef-ktw-fg, #fbf8f1); font-family: "Helvetica Neue", Arial, "Segoe UI", system-ui, sans-serif; }
.ef-ktw__c { position: absolute; inset: 0; width: 100%; height: 100%; display: block; touch-action: pan-y; }
.ef-ktw__txt { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.ef-ktw--plain .ef-ktw__txt { inset: 0; width: auto; height: auto; clip-path: none; white-space: normal; display: flex; align-items: center; justify-content: center; padding: 1rem; text-align: center; font-size: clamp(2.6rem, 14vw, 9rem); font-weight: 900; line-height: 0.95; letter-spacing: -0.04em; text-transform: uppercase; overflow-wrap: anywhere; }
.ef-ktw--plain .ef-ktw__c { display: none; }
@media (prefers-reduced-motion: reduce) { .ef-ktw__c { display: none; } .ef-ktw__txt { inset: 0; width: auto; height: auto; clip-path: none; white-space: normal; display: flex; align-items: center; justify-content: center; padding: 1rem; text-align: center; font-size: clamp(2.6rem, 14vw, 9rem); font-weight: 900; line-height: 0.95; letter-spacing: -0.04em; text-transform: uppercase; overflow-wrap: anywhere; } }JavaScript
// Enrichr Library · https://enrichr.dev
// Kinetic type warp: a giant headline drawn to a canvas and re-drawn as thin vertical strips. Scroll speed and pointer speed
// feed a sine wave that ripples the strips up and down, and the pointer adds a bulge that swells the letters under it.
// It reads window.scrollY itself, so it works with native scroll or any smooth-scroll library. With reduced motion it is plain text.
// mount(root, { text, strength (0-100), strip (strip width px), manual (0 = live, 1-100 = fixed warp %), color, accent, background })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var box = root.querySelector(".ef-ktw");
var canvas = root.querySelector(".ef-ktw__c");
var label = root.querySelector(".ef-ktw__txt");
var ctx = canvas.getContext("2d");
var tex = document.createElement("canvas"), ghost = document.createElement("canvas");
var FONT = "Helvetica Neue, Arial, Segoe UI, system-ui, sans-serif";
var opts = {}, raf = 0, visible = true, io = null, ro = null;
var lastT = 0, lastY = window.scrollY, energy = 0, phase = 0, dir = 1, dpr = 1;
var px = 0.5, tx = 0.5, bulge = 0, bulgeT = 0, pvx = 0, plastX = null, plastT = 0, idle = 0;
function plan(c, text, w, h) {
var words = text.trim().split(/\s+/);
function fit(ls) {
c.font = "900 100px " + FONT;
var mw = 0;
ls.forEach(function (l) { mw = Math.max(mw, c.measureText(l.toUpperCase()).width); });
return { ls: ls, size: Math.min(100 * (w * 0.94) / (mw || 1), (h * 0.72) / (ls.length * 0.9)) };
}
var best = fit([words.join(" ")]);
for (var i = 1; i < words.length; i++) {
var cand = fit([words.slice(0, i).join(" "), words.slice(i).join(" ")]);
if (cand.size > best.size * 1.05) best = cand;
}
return best;
}
function paintTex(t, color, w, h, p) {
t.width = w; t.height = h;
var c = t.getContext("2d");
c.fillStyle = color;
c.textAlign = "center"; c.textBaseline = "middle";
c.font = "900 " + p.size.toFixed(1) + "px " + FONT;
if ("letterSpacing" in c) c.letterSpacing = (-p.size * 0.03).toFixed(1) + "px";
var lh = p.size * 0.9, y0 = h / 2 - (p.ls.length - 1) * lh / 2;
p.ls.forEach(function (l, i) { c.fillText(l.toUpperCase(), w / 2, y0 + i * lh + p.size * 0.03); });
}
function build() {
var r = box.getBoundingClientRect();
dpr = Math.min(2, window.devicePixelRatio || 1);
var w = Math.max(40, Math.round(r.width * dpr)), h = Math.max(40, Math.round(r.height * dpr));
canvas.width = w; canvas.height = h;
var p = plan(ctx, opts.text, w, h);
paintTex(tex, opts.color, w, h, p);
paintTex(ghost, opts.accent, w, h, p);
draw();
}
function draw() {
var cw = canvas.width, ch = canvas.height, sw = Math.max(2, Math.round(opts.strip * dpr));
ctx.clearRect(0, 0, cw, ch);
var amp = energy * (opts.strength / 100), bAmt = bulge * (0.1 + 0.25 * opts.strength / 100);
var freq = 2.2 + energy * 1.6;
function pass(src, alpha, off) {
ctx.globalAlpha = alpha;
for (var x = 0; x < cw; x += sw) {
var nx = x / cw;
var wv = Math.sin(nx * freq * 6.2832 + phase + off) * amp;
var d = (nx - px) / 0.2;
var dh = ch * (1 + bAmt * Math.exp(-d * d));
ctx.drawImage(src, x, 0, sw, ch, x, (ch - dh) / 2 + wv * ch * 0.16, sw + 0.5, dh);
}
}
if (amp > 0.04) pass(ghost, Math.min(0.75, amp * 1.1), 0.7);
pass(tex, 1, 0);
ctx.globalAlpha = 1;
}
function tick(t) {
raf = requestAnimationFrame(tick);
var dt = Math.min(0.05, Math.max(0.001, (t - lastT) / 1000)); lastT = t;
var y = window.scrollY, sv = (y - lastY) / dt; lastY = y;
if (!visible) return;
var target;
if (opts.manual > 0) { target = opts.manual / 100; bulgeT = 0.7; tx = 0.5; }
else {
idle += dt;
target = Math.max(Math.min(1, Math.abs(sv) / 2400), Math.min(1, Math.abs(pvx) / 2.5) * 0.85, 0.07 + 0.04 * Math.sin(idle * 0.9));
if (Math.abs(sv) > 40) dir = sv > 0 ? 1 : -1;
pvx *= 0.9;
}
energy += (target - energy) * 0.12;
px += (tx - px) * 0.14; bulge += (bulgeT - bulge) * 0.1;
phase += dir * dt * (1.2 + energy * 9);
draw();
}
function move(e) {
var r = box.getBoundingClientRect(), now = performance.now(), nx = (e.clientX - r.left) / r.width;
if (plastX !== null && now > plastT) pvx = (nx - plastX) / ((now - plastT) / 1000);
plastX = nx; plastT = now; tx = Math.min(1, Math.max(0, nx));
if (Math.abs(pvx) > 0.05) dir = pvx > 0 ? 1 : -1;
bulgeT = 1;
}
function leave() { bulgeT = 0; plastX = null; tx = 0.5; }
function update(next) {
opts = Object.assign({ text: "Kinetic type", strength: 60, strip: 4, manual: 0, color: "#fbf8f1", accent: "#e6ff55", background: "#0c1e18" }, opts, next || {});
["strength", "strip", "manual"].forEach(function (k) { opts[k] = Number(opts[k]); });
box.style.setProperty("--ef-ktw-bg", opts.background);
box.style.setProperty("--ef-ktw-fg", opts.color);
label.textContent = opts.text;
if (!reduce) build();
}
update(o);
if (reduce) { box.classList.add("ef-ktw--plain"); return { update: update, destroy: function () {} }; }
box.addEventListener("pointermove", move);
box.addEventListener("pointerleave", leave);
if ("ResizeObserver" in window) { ro = new ResizeObserver(function () { build(); }); ro.observe(box); }
if ("IntersectionObserver" in window) { io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; }); io.observe(box); }
raf = requestAnimationFrame(tick);
return { update: update, destroy: function () {
cancelAnimationFrame(raf); if (io) io.disconnect(); if (ro) ro.disconnect();
box.removeEventListener("pointermove", move); box.removeEventListener("pointerleave", leave);
} };
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"text":"Kinetic type","strength":60,"strip":4,"manual":0,"color":"#fbf8f1","accent":"#e6ff55","background":"#0c1e18"});How it works
The headline is fitted to the box and drawn once to an off-screen canvas, in your colour and again in the echo colour. Every frame the visible canvas is rebuilt by copying that picture in thin vertical strips. Each strip is shifted up or down by a sine wave whose size follows an energy value, and strips near the pointer are stretched taller to make a bulge. Energy comes from scroll speed read off window.scrollY and from how fast the pointer moves, and it eases back to a gentle idle ripple. The echo copy is drawn first and slightly out of phase, which gives a coloured shadow while the type is moving.
Accessibility and performance
The canvas is hidden from assistive technology and the headline is also present as real text for screen readers. With prefers-reduced-motion the canvas is removed and the headline shows as ordinary styled text, with no warp. Keep the text and background contrast high, because the strips briefly stretch the letters.
One 2D canvas, redrawn each frame while on screen and paused when off screen. A 1200 px wide block at 4 px strips is about 300 small image copies per frame, which is light. Larger strips are cheaper, and the canvas resolution is capped at 2x.
| Section | Text |
|---|---|
| Techniques | Text animation, Scroll-driven, Pointer interaction |
| Stack | Vanilla JS, Canvas 2D |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| Inspired by | Kinetic Typography with Three.js by Mario Carrillo on Codrops. Original code, written from scratch. |
Find this technique on a real site
Want to see this technique in the wild? Check any site with the free tools, then build your own version from this page.
Check which animation library a site uses · Extract a site's design tokens · Browse site breakdowns
Licence and provenance
Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.
This is original code. The technique is a common one that we studied by measuring award-winning sites with Enrichr; no third-party code, shaders, fonts or images are used.
More elements
FAQ
Why strips instead of a WebGL shader?
Strips need no WebGL context and no extra files, and they behave the same on every browser. A shader version can bend the letters smoothly, but for a headline the strip look reads well and is cheap.
Does it need a smooth scroll library?
No. It reads window.scrollY every frame, so native scrolling, Lenis or any other method that moves the page will drive it.
Can I use a different font?
The canvas uses a bold system sans stack. To change it, edit the font string in the build step and keep a heavy weight, since thin letters blur when strips move.