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 interactive" for my site using the Enrichr Library element at https://enrichr.dev/elements/kinetic-type-interactive.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/kinetic-type-interactive.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:
- effect: magnet
- text: BEND THE LINE
- speed: 1
- size: 42
- color: #fbf8f1
- accent: #e6ff55
- background: #0c1e18
- label: 1
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-interactive.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-kti" data-fx="magnet" data-label="1" tabindex="0" role="group" aria-label="Interactive type study. With the keyboard, focus it and use the arrow keys to move the focus point.">
<div class="ef-kti__head">
<span class="ef-kti__num" aria-hidden="true">16</span>
<span class="ef-kti__meta"><span class="ef-kti__name">Magnetic pull</span><span class="ef-kti__desc">Letters lean toward your cursor as it nears.</span></span>
</div>
<div class="ef-kti__stage"><span class="ef-kti__demo">BEND THE LINE</span></div>
<span class="ef-kti__hint" aria-hidden="true">move your cursor</span>
</div>CSS
/* Enrichr Library · https://enrichr.dev */
.ef-kti {
--ef-kti-k: 1; --ef-kti-size: 42;
--ef-kti-fg: #fbf8f1; --ef-kti-fg-rgb: 251, 248, 241; --ef-kti-muted: #9ca39b;
--ef-kti-bg: #0c1e18; --ef-kti-bg-rgb: 12, 30, 24; --ef-kti-top: #17281f;
--ef-kti-acc: #e6ff55; --ef-kti-acc-rgb: 230, 255, 85; --ef-kti-p: 0%;
--ef-kti-fs: min(calc(var(--ef-kti-size) * 1px), max(1.5rem, 9cqw));
position: relative; box-sizing: border-box; width: 100%; min-height: 24rem; overflow: hidden; border-radius: 1rem;
display: flex; flex-direction: column; container-type: inline-size; touch-action: pan-y;
color: var(--ef-kti-fg); font-family: 'Instrument Sans', system-ui, sans-serif;
background: radial-gradient(ellipse at 50% 120%, rgba(var(--ef-kti-acc-rgb), .1), transparent 60%), linear-gradient(180deg, var(--ef-kti-top), var(--ef-kti-bg));
border: 1px solid rgba(var(--ef-kti-acc-rgb), .2);
box-shadow: inset 0 1px 0 rgba(var(--ef-kti-fg-rgb), .05);
}
.ef-kti *, .ef-kti *::before, .ef-kti *::after { box-sizing: border-box; }
.ef-kti:focus { outline: none; }
.ef-kti:focus-visible { outline: 2px solid var(--ef-kti-acc); outline-offset: -4px; }
/* specimen label */
.ef-kti .ef-kti__head { position: relative; z-index: 2; display: flex; align-items: center; gap: 1rem; padding: 1.1rem 1.25rem 0; }
.ef-kti[data-label="0"] .ef-kti__head, .ef-kti[data-label="0"] .ef-kti__hint { display: none; }
.ef-kti .ef-kti__num {
flex-shrink: 0; width: 2.2rem; height: 2.2rem; border-radius: 50%; display: flex; align-items: center; justify-content: center;
font: 600 .78rem/1 'Fira Code', ui-monospace, monospace; color: var(--ef-kti-bg); background: var(--ef-kti-acc);
box-shadow: 0 0 0 1px rgba(var(--ef-kti-acc-rgb), .4), 0 4px 14px -4px rgba(var(--ef-kti-acc-rgb), .55);
}
.ef-kti .ef-kti__meta { display: block; min-width: 0; text-align: left; }
.ef-kti .ef-kti__name { display: block; margin: 0 0 .15rem; font: 600 1.1rem/1.2 'Parkinsans', 'Space Grotesk', sans-serif; letter-spacing: .01em; color: var(--ef-kti-fg); opacity: 1; }
.ef-kti .ef-kti__desc { display: block; margin: 0; font-size: .84rem; line-height: 1.45; color: var(--ef-kti-muted); opacity: 1; }
.ef-kti.is-narrow .ef-kti__desc { display: none; }
.ef-kti .ef-kti__hint {
position: absolute; z-index: 2; bottom: .9rem; right: .9rem; padding: .3rem .6rem; border-radius: 2px;
font: .68rem/1.2 'Fira Code', ui-monospace, monospace; letter-spacing: .06em; text-transform: uppercase; color: var(--ef-kti-fg);
background: rgba(var(--ef-kti-bg-rgb), .65); border: 1px solid rgba(var(--ef-kti-acc-rgb), .3);
-webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px); pointer-events: none;
}
/* stage and type */
.ef-kti .ef-kti__stage { position: relative; z-index: 1; flex: 1; display: flex; align-items: center; justify-content: center; text-align: center; padding: 2rem 1.5rem 3rem; min-height: 12rem; }
.ef-kti .ef-kti__sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
.ef-kti .ef-kti__demo {
display: inline-block; margin: 0; white-space: pre-wrap; color: var(--ef-kti-fg); opacity: 1;
font-family: 'Parkinsans', 'Space Grotesk', sans-serif; font-weight: 500; font-size: var(--ef-kti-fs); letter-spacing: .01em; line-height: 1.2;
}
.ef-kti .ef-kti__w { display: inline-block; white-space: nowrap; }
.ef-kti .ef-kti__ch { display: inline-block; will-change: transform, opacity, filter; }
/* 16 magnetic pull, 19 weight morph, 20 cursor distort */
.ef-kti[data-fx="magnet"] .ef-kti__ch, .ef-kti[data-fx="pressure"] .ef-kti__ch, .ef-kti[data-fx="distort"] .ef-kti__ch { transition: transform calc(.15s * var(--ef-kti-k)) ease-out; }
/* 17 scroll scrub */
.ef-kti[data-fx="scrub"] .ef-kti__demo {
background: linear-gradient(90deg, var(--ef-kti-fg) var(--ef-kti-p), var(--ef-kti-muted) var(--ef-kti-p));
-webkit-background-clip: text; background-clip: text; color: transparent;
}
/* 18 hover scatter */
.ef-kti[data-fx="scatter"] .ef-kti__ch { transition: transform calc(.5s * var(--ef-kti-k)) cubic-bezier(.2, .9, .3, 1.1); }
/* reduced motion: no letter movement; hover and focus only change the colour */
.ef-kti.is-reduced .ef-kti__ch { transition: none !important; transform: none !important; }
.ef-kti.is-reduced.is-hot .ef-kti__demo { color: var(--ef-kti-acc); }
.ef-kti.is-reduced.is-hot[data-fx="scrub"] .ef-kti__demo { color: transparent; }
@media (prefers-reduced-motion: reduce) {
.ef-kti .ef-kti__ch { transition: none !important; }
}JavaScript
// Enrichr Library · https://enrichr.dev
// Kinetic type interactive: the five studies from a kinetic type specimen book that answer the pointer or the scroll, one
// at a time (pick it with effect). Magnetic pull: every letter moves toward the pointer by 35% of the distance times
// min(1, 90 / distance), so close letters lean hard and far ones barely move, easing over 0.15 s. Scroll scrub: the word
// fills from the left with the text colour in step with how far the element has travelled up the screen. Hover scatter:
// on hover every letter jumps up to 40 px sideways, 30 px up or down and 40 degrees round, then springs back on leave over
// 0.5 s on cubic-bezier(.2,.9,.3,1.1). Weight morph: each letter's weight is 900 minus 6 per pixel of horizontal distance
// from the pointer, never below 300 (the font tops out at 800). Cursor distort: letters within 120 px of the pointer skew
// up to 18 degrees away from it and grow up to 40%. Keyboard: focus the element and the arrow keys move a focus point
// across the text (focus alone plays the scatter); Escape lets go. Reduced motion: letters never move; hover and focus only
// change the colour, while the weight morph and the scroll fill (which do not move anything) still work.
// mount(root, { effect (magnet | scrub | scatter | pressure | distort), text, speed (1 = original timing), size (px),
// color, accent, background, label (1 shows the specimen label and hint, 0 hides them) })
function mount(root, o) {
var reduce = !!(window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches);
var box = root.querySelector(".ef-kti");
var numEl = box.querySelector(".ef-kti__num"), nameEl = box.querySelector(".ef-kti__name");
var descEl = box.querySelector(".ef-kti__desc"), hintEl = box.querySelector(".ef-kti__hint");
var stage = box.querySelector(".ef-kti__stage");
var FX = {
magnet: ["16", "Magnetic pull", "Letters lean toward your cursor as it nears.", "move your cursor"],
scrub: ["17", "Scroll scrub", "Colour fills the word in step with how far it has scrolled.", "scroll the page"],
scatter: ["18", "Hover scatter", "The word breaks apart at a touch, then finds its way back.", "hover"],
pressure: ["19", "Weight morph", "Letters gain weight the closer your cursor sits.", "move your cursor"],
distort: ["20", "Cursor distort", "A fisheye of skew and scale follows your pointer.", "move your cursor"]
};
var opts = {}, fx = "", text = "", demo = null, spans = [], visible = true, io = null, ro = null, destroyed = false;
var vp = null; // keyboard focus point, as fractions of the stage box
function num(v, d) { v = Number(v); return isFinite(v) ? v : d; }
function rgb(h, d) { var m = /^#?([0-9a-f]{6})$/i.exec(String(h || "")); var v = parseInt(m ? m[1] : d, 16); return [v >> 16, (v >> 8) & 255, v & 255]; }
function mix(a, b, t) { return "rgb(" + [0, 1, 2].map(function (i) { return Math.round(a[i] + (b[i] - a[i]) * t); }).join(",") + ")"; }
function rand(a, b) { return Math.random() * (b - a) + a; }
function make(tag, cls, txt) { var e = document.createElement(tag); if (cls) e.className = cls; if (txt != null) e.textContent = txt; return e; }
function follows() { return fx === "magnet" || fx === "pressure" || fx === "distort"; }
function build() {
fx = FX[opts.effect] ? opts.effect : "magnet";
var f = FX[fx];
box.setAttribute("data-fx", fx);
numEl.textContent = f[0]; nameEl.textContent = f[1]; descEl.textContent = f[2]; hintEl.textContent = f[3];
text = String(opts.text == null ? "" : opts.text).replace(/\s+/g, " ").trim() || "BEND THE LINE";
stage.textContent = ""; spans = []; vp = null;
box.classList.remove("is-hot");
stage.appendChild(make("span", "ef-kti__sr", text));
demo = make("span", "ef-kti__demo"); demo.setAttribute("aria-hidden", "true");
if (fx === "scrub") demo.textContent = text;
else {
var word = null;
Array.from(text).forEach(function (ch) {
if (/\s/.test(ch)) { word = null; demo.appendChild(document.createTextNode(" ")); return; }
if (!word) { word = make("span", "ef-kti__w"); demo.appendChild(word); }
var s = make("span", "ef-kti__ch", ch);
word.appendChild(s); spans.push(s);
});
}
stage.appendChild(demo);
scrub();
}
// the pointer maths, unchanged from the original; letters are measured where they are drawn right now
function at(mx, my) {
if (fx === "magnet") {
if (reduce) return;
spans.forEach(function (s) {
var r = s.getBoundingClientRect(), dx = mx - (r.left + r.width / 2), dy = my - (r.top + r.height / 2);
var dist = Math.max(30, Math.sqrt(dx * dx + dy * dy)), pull = Math.min(1, 90 / dist);
s.style.transform = "translate(" + dx * pull * 0.35 + "px, " + dy * pull * 0.35 + "px)";
});
} else if (fx === "pressure") {
spans.forEach(function (s) {
var r = s.getBoundingClientRect(), dist = Math.abs(mx - (r.left + r.width / 2));
s.style.fontVariationSettings = '"wght" ' + Math.max(300, 900 - dist * 6);
});
} else if (fx === "distort") {
if (reduce) return;
spans.forEach(function (s) {
var r = s.getBoundingClientRect(), dist = mx - (r.left + r.width / 2);
var prox = Math.max(0, 1 - Math.abs(dist) / 120), skew = (dist > 0 ? -1 : 1) * prox * 18, scale = 1 + prox * 0.4;
s.style.transform = "skewX(" + skew + "deg) scale(" + scale + ")";
});
}
}
function scatter() {
if (fx !== "scatter" || reduce) return;
spans.forEach(function (s) { s.style.transform = "translate(" + rand(-40, 40) + "px, " + rand(-30, 30) + "px) rotate(" + rand(-40, 40) + "deg)"; });
}
function release() {
box.classList.remove("is-hot");
spans.forEach(function (s) { s.style.transform = ""; if (fx === "pressure") s.style.fontVariationSettings = '"wght" 400'; });
}
function scrub() {
if (fx !== "scrub" || !visible) return;
var r = box.getBoundingClientRect(), vh = window.innerHeight || document.documentElement.clientHeight;
var p = 1 - (r.top + r.height / 2) / (vh + r.height / 2);
p = Math.max(0, Math.min(1, p));
box.style.setProperty("--ef-kti-p", (p * 100).toFixed(1) + "%");
}
function virtual() {
var r = stage.getBoundingClientRect();
at(r.left + r.width * vp.x, r.top + r.height * vp.y);
}
function onMove(e) { if (follows()) at(e.clientX, e.clientY); }
function onEnter() { box.classList.add("is-hot"); scatter(); }
function onLeave() { if (!vp) release(); }
function onFocus() {
if (!box.matches(":focus-visible")) return;
box.classList.add("is-hot");
if (fx === "scatter") scatter();
else if (follows()) {
var r = stage.getBoundingClientRect(), d = demo.getBoundingClientRect();
vp = { x: (d.left + d.width * 0.2 - r.left) / r.width, y: (d.top + d.height / 2 - r.top) / r.height };
virtual();
}
}
function onBlur() { vp = null; release(); }
function onKey(e) {
if (e.key === "Escape") { vp = null; release(); return; }
if (fx === "scatter" && (e.key === "Enter" || e.key === " ")) { e.preventDefault(); box.classList.add("is-hot"); scatter(); return; }
if (!vp || !follows()) return;
var step = { ArrowLeft: [-0.04, 0], ArrowRight: [0.04, 0], ArrowUp: [0, -0.06], ArrowDown: [0, 0.06] }[e.key];
if (!step || (step[1] && fx !== "magnet")) return;
e.preventDefault();
vp.x = Math.max(0, Math.min(1, vp.x + step[0])); vp.y = Math.max(0, Math.min(1, vp.y + step[1]));
virtual();
}
function paint() {
var fg = rgb(opts.color, "fbf8f1"), bg = rgb(opts.background, "0c1e18"), ac = rgb(opts.accent, "e6ff55"), s = box.style;
s.setProperty("--ef-kti-fg", "rgb(" + fg + ")"); s.setProperty("--ef-kti-fg-rgb", fg.join(","));
s.setProperty("--ef-kti-bg", "rgb(" + bg + ")"); s.setProperty("--ef-kti-bg-rgb", bg.join(","));
s.setProperty("--ef-kti-top", mix(bg, fg, 0.045)); s.setProperty("--ef-kti-muted", mix(bg, fg, 0.62));
s.setProperty("--ef-kti-acc", "rgb(" + ac + ")"); s.setProperty("--ef-kti-acc-rgb", ac.join(","));
s.setProperty("--ef-kti-k", String(1 / opts.speed));
s.setProperty("--ef-kti-size", String(opts.size));
box.setAttribute("data-label", opts.label ? "1" : "0");
box.classList.toggle("is-reduced", reduce);
}
function update(partial) {
var prev = opts;
opts = Object.assign({ effect: "magnet", text: "BEND THE LINE", speed: 1, size: 42, color: "#fbf8f1", accent: "#e6ff55", background: "#0c1e18", label: 1 }, opts, partial || {});
opts.speed = Math.min(5, Math.max(0.1, num(opts.speed, 1)));
opts.size = Math.max(10, num(opts.size, 42));
opts.label = !(opts.label === 0 || opts.label === "0" || opts.label === false || opts.label === "off");
paint();
if (destroyed) return;
if (!fx || prev.effect !== opts.effect || prev.text !== opts.text) build();
}
update(o);
box.addEventListener("pointermove", onMove);
box.addEventListener("pointerenter", onEnter);
box.addEventListener("pointerleave", onLeave);
box.addEventListener("focus", onFocus);
box.addEventListener("blur", onBlur);
box.addEventListener("keydown", onKey);
window.addEventListener("scroll", scrub, { passive: true });
window.addEventListener("resize", scrub);
if ("IntersectionObserver" in window) {
io = new IntersectionObserver(function (es) { visible = es[es.length - 1].isIntersecting; scrub(); });
io.observe(box);
}
if ("ResizeObserver" in window) {
ro = new ResizeObserver(function () { if (destroyed) return; box.classList.toggle("is-narrow", box.clientWidth < 520); scrub(); });
ro.observe(box);
}
return {
update: update,
destroy: function () {
destroyed = true;
box.removeEventListener("pointermove", onMove);
box.removeEventListener("pointerenter", onEnter);
box.removeEventListener("pointerleave", onLeave);
box.removeEventListener("focus", onFocus);
box.removeEventListener("blur", onBlur);
box.removeEventListener("keydown", onKey);
window.removeEventListener("scroll", scrub);
window.removeEventListener("resize", scrub);
if (io) io.disconnect();
if (ro) ro.disconnect();
}
};
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"effect":"magnet","text":"BEND THE LINE","speed":1,"size":42,"color":"#fbf8f1","accent":"#e6ff55","background":"#0c1e18","label":1});How it works
The line is split into letter spans and every pointer move measures each letter where it is drawn right now. Magnetic pull moves a letter toward the pointer by 35% of the distance times min(1, 90 / distance), easing over 0.15 s, so near letters lean hard and far ones barely move. Weight morph sets each letter's weight to 900 minus 6 for every pixel of horizontal distance, never below 300, through font-variation-settings on the variable brand font (its axis tops out at 800). Cursor distort skews letters within 120 px up to 18 degrees away from the pointer and scales them up to 140%. Hover scatter throws every letter up to 40 px sideways, 30 px up or down and 40 degrees round, and springs it back on leave over 0.5 s on cubic-bezier(.2,.9,.3,1.1). Scroll scrub fills the word from the left with a hard-edged gradient whose stop follows how far the element has travelled up the screen, read from getBoundingClientRect on a passive scroll listener. Speed divides the transition times.
Accessibility and performance
The line is visually hidden text for screen readers and the moving letters are hidden from them. The element is focusable: focus plays the hover scatter (Enter or Space scatters again), and for the cursor studies the arrow keys move a focus point across the text; Escape lets go. With prefers-reduced-motion letters never move: hover and focus only change the colour, while the weight morph and the scroll fill, which move nothing, still work. Touch works by dragging sideways; vertical swipes still scroll the page.
Each pointer move reads a few dozen letter boxes and writes one transform or weight each, with no animation loop. The scroll listener is passive and only does work while the element is on screen.
| Section | Text |
|---|---|
| Techniques | Text animation, Pointer interaction, Scroll-driven |
| Stack | Vanilla JS, CSS |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| On CodePen | Open the live pen on our CodePen account; fork it to experiment. |
| Inspired by | Text Animations by Enrichr on CodePen. 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
Does the weight morph need a special font?
It needs a variable font with a weight axis. The Enrichr fonts are variable; with a font that only has fixed weights the letters snap between them instead of swelling smoothly.
Why does the scroll fill not move in a short page?
It follows the element's position on screen, from entering at the bottom to leaving at the top. If the page cannot scroll, the fill stays where it is.
Can keyboard users try the cursor effects?
Yes. Tab to the element and use the arrow keys: left and right move the focus point along the line, and up and down also work for the magnetic pull.