Copied!
Text

Kinetic type warp

A giant canvas headline sliced into strips that ripple with scroll speed and swell under the pointer.

← Library

Element · Vanilla JS + Canvas 2DUpdated November 5, 2026

TextElementVanilla JS + Canvas 2D

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.

SectionText
TechniquesText animation, Scroll-driven, Pointer interaction
StackVanilla JS, Canvas 2D
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired byKinetic 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.

Word read highlightWords in a paragraph light up one by one as it scrolls through the viewport. Scroll velocity skewText that leans and splits into two colour fringes in proportion to how fast the page scrolls. Scroll spring headingAn oversized heading slides sideways with scroll progress through a spring, lagging and settling.

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.