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 experiments" for my site using the Enrichr Library element at https://enrichr.dev/elements/kinetic-type-experiments.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/kinetic-type-experiments.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: varfont
- text: A LIVING AXIS
- words: FORM, FUNCTION
- speed: 1
- size: 42
- color: #fbf8f1
- accent: #e6ff55
- ghost: #e54c1e
- 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-experiments.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-ktx" data-fx="varfont" data-label="1">
<div class="ef-ktx__head">
<span class="ef-ktx__num" aria-hidden="true">25</span>
<span class="ef-ktx__meta"><span class="ef-ktx__name">Variable weight</span><span class="ef-ktx__desc">One font, breathing between its thinnest and heaviest self.</span></span>
<span class="ef-ktx__tag">continuous</span>
</div>
<div class="ef-ktx__stage"><span class="ef-ktx__demo">A LIVING AXIS</span></div>
<span class="ef-ktx__hint" aria-hidden="true" hidden>scroll the page</span>
</div>CSS
/* Enrichr Library · https://enrichr.dev */
.ef-ktx {
--ef-ktx-k: 1; --ef-ktx-size: 42;
--ef-ktx-fg: #fbf8f1; --ef-ktx-fg-rgb: 251, 248, 241; --ef-ktx-muted: #9ca39b;
--ef-ktx-bg: #0c1e18; --ef-ktx-bg-rgb: 12, 30, 24; --ef-ktx-top: #17281f;
--ef-ktx-acc: #e6ff55; --ef-ktx-acc-rgb: 230, 255, 85; --ef-ktx-ghost-rgb: 229, 76, 30;
--ef-ktx-fs: min(calc(var(--ef-ktx-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;
color: var(--ef-ktx-fg); font-family: 'Instrument Sans', system-ui, sans-serif;
background: radial-gradient(ellipse at 50% 120%, rgba(var(--ef-ktx-acc-rgb), .1), transparent 60%), linear-gradient(180deg, var(--ef-ktx-top), var(--ef-ktx-bg));
border: 1px solid rgba(var(--ef-ktx-acc-rgb), .2);
box-shadow: inset 0 1px 0 rgba(var(--ef-ktx-fg-rgb), .05);
}
.ef-ktx *, .ef-ktx *::before, .ef-ktx *::after { box-sizing: border-box; }
/* specimen label */
.ef-ktx .ef-ktx__head { position: relative; z-index: 2; display: flex; align-items: center; gap: 1rem; padding: 1.1rem 1.25rem 0; }
.ef-ktx[data-label="0"] .ef-ktx__head, .ef-ktx[data-label="0"] .ef-ktx__hint { display: none; }
.ef-ktx .ef-ktx__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-ktx-bg); background: var(--ef-ktx-acc);
box-shadow: 0 0 0 1px rgba(var(--ef-ktx-acc-rgb), .4), 0 4px 14px -4px rgba(var(--ef-ktx-acc-rgb), .55);
}
.ef-ktx .ef-ktx__meta { display: block; min-width: 0; text-align: left; }
.ef-ktx .ef-ktx__name { display: block; margin: 0 0 .15rem; font: 600 1.1rem/1.2 'Parkinsans', 'Space Grotesk', sans-serif; letter-spacing: .01em; color: var(--ef-ktx-fg); opacity: 1; }
.ef-ktx .ef-ktx__desc { display: block; margin: 0; font-size: .84rem; line-height: 1.45; color: var(--ef-ktx-muted); opacity: 1; }
.ef-ktx.is-narrow .ef-ktx__desc { display: none; }
.ef-ktx .ef-ktx__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-ktx-fg);
background: rgba(var(--ef-ktx-bg-rgb), .65); border: 1px solid rgba(var(--ef-ktx-acc-rgb), .3);
-webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px); pointer-events: none;
}
/* stage and type */
.ef-ktx .ef-ktx__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-ktx .ef-ktx__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-ktx .ef-ktx__demo {
display: inline-block; margin: 0; white-space: pre-wrap; color: var(--ef-ktx-fg); opacity: 1;
font-family: 'Parkinsans', 'Space Grotesk', sans-serif; font-weight: 500; font-size: var(--ef-ktx-fs); letter-spacing: .01em; line-height: 1.2;
}
.ef-ktx .ef-ktx__w { display: inline-block; white-space: nowrap; }
.ef-ktx .ef-ktx__ch { display: inline-block; will-change: transform, opacity, filter; }
.ef-ktx .ef-ktx__tag {
margin-left: auto; align-self: center; white-space: nowrap; padding: .3rem .6rem; border-radius: 20px;
font: .62rem/1.2 'Fira Code', ui-monospace, monospace; letter-spacing: .08em; text-transform: uppercase;
color: rgba(var(--ef-ktx-acc-rgb), .85); border: 1px solid rgba(var(--ef-ktx-acc-rgb), .35); background: rgba(var(--ef-ktx-acc-rgb), .06);
}
.ef-ktx .ef-ktx__tag[hidden], .ef-ktx .ef-ktx__hint[hidden], .ef-ktx.is-narrow .ef-ktx__tag { display: none; }
/* 25 variable weight (the font's weight axis runs 300 to 800, so the peak holds at 800) */
.ef-ktx[data-fx="varfont"] .ef-ktx__demo { font-variation-settings: "wght" 340, "opsz" 30; }
.ef-ktx[data-fx="varfont"].is-in .ef-ktx__demo { animation: ef-ktx-wght calc(4s * var(--ef-ktx-k)) ease-in-out infinite; }
@keyframes ef-ktx-wght {
0%, 100% { font-variation-settings: "wght" 300, "opsz" 20; }
50% { font-variation-settings: "wght" 900, "opsz" 100; }
}
/* 26 matrix formation */
.ef-ktx .ef-ktx__rain { display: block; max-width: 100%; height: auto; }
/* 27 word morph */
.ef-ktx .ef-ktx__morph { position: relative; display: inline-block; min-width: 1px; }
.ef-ktx .ef-ktx__morph .ef-ktx__demo { position: relative; white-space: nowrap; transition: opacity calc(1s * var(--ef-ktx-k)) ease, filter calc(1s * var(--ef-ktx-k)) ease; }
.ef-ktx .ef-ktx__morph .ef-ktx__a { opacity: 1; filter: blur(0); }
.ef-ktx .ef-ktx__morph .ef-ktx__b { position: absolute; left: 0; top: 0; opacity: 0; filter: blur(6px); }
.ef-ktx .ef-ktx__morph.is-swap .ef-ktx__a { opacity: 0; filter: blur(6px); }
.ef-ktx .ef-ktx__morph.is-swap .ef-ktx__b { opacity: 1; filter: blur(0); }
/* 28 chromatic scroll */
.ef-ktx[data-fx="chroma"] .ef-ktx__demo { transition: text-shadow calc(.12s * var(--ef-ktx-k)) linear; }
/* 29 gravity drop (160px, 8px and 4px at the default 42px size) */
.ef-ktx[data-fx="gravity"] .ef-ktx__ch { opacity: 0; transform: translateY(-3.81em); }
.ef-ktx[data-fx="gravity"].is-in .ef-ktx__ch { animation: ef-ktx-drop calc(.7s * var(--ef-ktx-k)) cubic-bezier(.4, 0, .2, 1) forwards; animation-delay: calc(var(--d, 0ms) * var(--ef-ktx-k)); }
@keyframes ef-ktx-drop {
0% { opacity: 0; transform: translateY(-3.81em); }
60% { opacity: 1; transform: translateY(.19em); }
80% { transform: translateY(-.095em); }
100% { opacity: 1; transform: translateY(0); }
}
/* off screen and reduced motion */
.ef-ktx.is-paused * { animation-play-state: paused !important; }
.ef-ktx.is-reduced *, .ef-ktx.is-reduced *::before, .ef-ktx.is-reduced *::after { transition: none !important; animation: none !important; }
.ef-ktx.is-reduced[data-fx="gravity"] .ef-ktx__ch { opacity: 1; transform: none; }
@media (prefers-reduced-motion: reduce) {
.ef-ktx *, .ef-ktx *::before, .ef-ktx *::after { transition: none !important; animation: none !important; }
}JavaScript
// Enrichr Library · https://enrichr.dev
// Kinetic type experiments: the five experimental studies from a kinetic type specimen book, one at a time (pick it with
// effect). Variable weight: the word breathes between its thinnest and heaviest weight on a 4 s ease-in-out loop (the
// font's axis tops out at 800). Matrix formation: a canvas of 22 px glyph rain, one row per frame at 60 frames a second,
// each frame laid over the last at 35% so trails fade; the letters of the word lock in, every other column, on row four as
// their column's drop reaches it. Word morph: two words swap every 2.2 s, each fading and blurring 6 px over 1 s.
// Chromatic scroll: two coloured shadows split up to 8 px either side the further the element sits from the middle of the
// screen, and close up at the centre. Gravity drop: letters fall 160 px, overshoot 8 px, bounce back 4 px and settle over
// 0.7 s, start times spread over 420 ms. Matrix and gravity play when the element comes into view and again each time it
// returns; replay() plays them on demand. Speed scales every duration. Reduced motion shows a still, finished frame of
// each (the matrix as its locked word, no colour split). Loops pause off screen and in hidden tabs.
// mount(root, { effect (varfont | matrix | morph | chroma | gravity), text, words (morph words, comma separated),
// speed (1 = original timing), size (px), color, accent, ghost (second split colour), 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-ktx");
var numEl = box.querySelector(".ef-ktx__num"), nameEl = box.querySelector(".ef-ktx__name");
var descEl = box.querySelector(".ef-ktx__desc"), tagEl = box.querySelector(".ef-ktx__tag"), hintEl = box.querySelector(".ef-ktx__hint");
var stage = box.querySelector(".ef-ktx__stage");
var CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ#%&*+-/<>[]{}01";
var AUTO = "auto · repeats on view", LOOP = "continuous";
var FX = {
varfont: ["25", "Variable weight", "One font, breathing between its thinnest and heaviest self.", LOOP, ""],
matrix: ["26", "Matrix formation", "A field of falling characters locks into the word.", AUTO, ""],
morph: ["27", "Word morph", "One word dissolves as another takes its place.", LOOP, ""],
chroma: ["28", "Chromatic scroll", "Colour channels split apart near the edges of view, resolve at centre.", "", "scroll the page"],
gravity: ["29", "Gravity drop", "Letters fall, overshoot, and settle under their own weight.", AUTO, ""]
};
var opts = {}, fx = "", text = "", demo = null, inView = false, visible = true, io = null, ro = null, destroyed = false;
var timers = [], clock = 0, last = 0, raf = 0, rain = null, words = [];
var fg = [251, 248, 241], bg = [12, 30, 24], ac = [230, 255, 85], gh = [229, 76, 30];
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 rgba(c, a) { return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")"; }
function randInt(a, b) { return Math.floor(Math.random() * (b - a + 1)) + a; }
function make(tag, cls, txt) { var e = document.createElement(tag); if (cls) e.className = cls; if (txt != null) e.textContent = txt; return e; }
// one clock for the morph timer and the matrix frames: it only runs on screen in a visible tab, scaled by speed
function tick(now) {
raf = 0;
var dt = Math.min(50, Math.max(0, now - last)); last = now;
if (visible && !document.hidden) {
clock += dt * opts.speed;
var list = timers.slice();
for (var i = 0; i < list.length; i++) {
var t = list[i], n = 0;
while (!t.dead && t.at <= clock && n++ < 4) { if (t.every) t.at += t.every; else t.dead = true; t.fn(t); }
}
timers = timers.filter(function (t) { return !t.dead; });
}
if (timers.length && !destroyed && visible) raf = requestAnimationFrame(tick);
}
function kick() { if (!raf && !destroyed && timers.length) { last = performance.now(); raf = requestAnimationFrame(tick); } }
function every(ms, fn) { var t = { at: clock + ms, every: ms, fn: fn }; timers.push(t); kick(); return t; }
function stopTimers() { timers.forEach(function (t) { t.dead = true; }); timers = []; }
// ---- 26 matrix formation: the original's maths on a canvas sized to fit the word (560 x 150 for short words) ----
function matrixSetup() {
var letters = Array.from(text), len = letters.length, cols = 25;
while (Math.floor(cols / 2 - len * 1.1 / 2) + (len - 1) * 2 > cols - 1) cols++;
var W = cols * 22 + 10, H = 150, dpr = Math.min(window.devicePixelRatio || 1, 2);
var c = make("canvas", "ef-ktx__rain");
c.setAttribute("role", "img"); c.setAttribute("aria-label", "Falling characters that lock into the word " + text);
c.width = Math.round(W * dpr); c.height = Math.round(H * dpr); c.style.width = W + "px";
var ctx = c.getContext("2d"); ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
rain = { c: c, ctx: ctx, W: W, H: H, cols: cols, letters: letters, drops: [], locked: [] };
return c;
}
function matrixReset() {
var r = rain, fs = 22;
r.drops = []; r.locked = [];
for (var c = 0; c < r.cols; c++) { r.drops.push(-randInt(0, 20)); r.locked.push(null); }
var startCol = Math.floor(r.cols / 2 - r.letters.length * 1.1 / 2);
for (var i = 0; i < r.letters.length; i++) {
var col = startCol + i * 2;
if (col >= 0 && col < r.cols) r.locked[col] = { ch: r.letters[i], row: 4 };
}
r.ctx.fillStyle = "rgb(" + bg + ")"; r.ctx.fillRect(0, 0, r.W, r.H);
r.ctx.font = fs + "px 'Fira Code', ui-monospace, monospace";
}
function matrixFrame() {
var r = rain, fs = 22, ctx = r.ctx;
ctx.fillStyle = rgba(bg, 0.35);
ctx.fillRect(0, 0, r.W, r.H);
for (var c = 0; c < r.cols; c++) {
var x = c * fs, target = r.locked[c];
if (target && r.drops[c] >= target.row) {
ctx.fillStyle = "rgb(" + ac + ")";
ctx.fillText(target.ch, x, target.row * fs);
} else {
ctx.fillStyle = rgba(fg, 0.55);
ctx.fillText(CHARS[randInt(0, CHARS.length - 1)], x, r.drops[c] * fs);
r.drops[c]++;
if (r.drops[c] * fs > r.H) r.drops[c] = 0;
}
}
}
function matrixStill() {
var r = rain, fs = 22, ctx = r.ctx;
matrixReset();
ctx.fillStyle = "rgb(" + ac + ")";
r.locked.forEach(function (t, c) { if (t) ctx.fillText(t.ch, c * fs, t.row * fs); });
}
function chroma() {
if (fx !== "chroma" || !visible || reduce || !demo) return;
var r = box.getBoundingClientRect(), vh = window.innerHeight || document.documentElement.clientHeight;
var offset = Math.max(-1, Math.min(1, (r.top + r.height / 2 - vh / 2) / (vh / 2)));
var mag = Math.abs(offset) * 8;
demo.style.textShadow = offset * mag + "px 0 " + rgba(gh, 0.7) + ", " + -offset * mag + "px 0 " + rgba(ac, 0.7);
}
function build() {
stopTimers();
fx = FX[opts.effect] ? opts.effect : "varfont";
var f = FX[fx];
box.setAttribute("data-fx", fx);
box.classList.remove("is-in");
numEl.textContent = f[0]; nameEl.textContent = f[1]; descEl.textContent = f[2];
tagEl.textContent = f[3]; tagEl.hidden = !f[3]; hintEl.textContent = f[4]; hintEl.hidden = !f[4];
text = String(opts.text == null ? "" : opts.text).replace(/\s+/g, " ").trim() || "A LIVING AXIS";
words = String(opts.words || "").split(",").map(function (w) { return w.trim(); }).filter(Boolean);
if (!words.length) words = ["FORM", "FUNCTION"];
stage.textContent = ""; rain = null; demo = null;
if (fx !== "matrix") stage.appendChild(make("span", "ef-ktx__sr", fx === "morph" ? words.join(", ") : text));
if (fx === "matrix") stage.appendChild(matrixSetup());
else if (fx === "morph") {
var wrap = make("span", "ef-ktx__morph"); wrap.setAttribute("aria-hidden", "true");
wrap.appendChild(make("span", "ef-ktx__demo ef-ktx__a", words[0]));
wrap.appendChild(make("span", "ef-ktx__demo ef-ktx__b", words[1 % words.length]));
stage.appendChild(wrap);
} else {
demo = make("span", "ef-ktx__demo"); demo.setAttribute("aria-hidden", "true");
if (fx === "gravity") {
var chars = Array.from(text), len = Math.max(chars.length - 1, 1), word = null;
chars.forEach(function (ch, i) {
if (/\s/.test(ch)) { word = null; demo.appendChild(document.createTextNode(" ")); return; }
if (!word) { word = make("span", "ef-ktx__w"); demo.appendChild(word); }
var s = make("span", "ef-ktx__ch", ch);
s.style.setProperty("--d", (i / len * 420).toFixed(0) + "ms");
word.appendChild(s);
});
} else demo.textContent = text;
stage.appendChild(demo);
}
if (reduce) { box.classList.add("is-in"); if (rain) matrixStill(); return; }
if (fx === "morph") startMorph();
if (fx === "chroma") chroma();
if (inView) play();
}
function startMorph() {
var wrap = stage.querySelector(".ef-ktx__morph"), a = wrap.firstChild, b = wrap.lastChild, cur = 0, onB = false;
every(2200, function () {
var next = (cur + 1) % words.length;
(onB ? a : b).textContent = words[next]; // the incoming word; with two words it is already there
onB = !onB; cur = next;
wrap.classList.toggle("is-swap", onB);
});
}
function play() {
if (reduce || destroyed) return;
box.classList.remove("is-in");
void box.offsetWidth; // commit the start state so the drop runs again
box.classList.add("is-in");
if (rain) {
stopTimers();
matrixReset();
matrixFrame();
every(1000 / 60, matrixFrame);
}
}
function replay() { if (fx === "morph") build(); else play(); }
function paint() {
fg = rgb(opts.color, "fbf8f1"); bg = rgb(opts.background, "0c1e18"); ac = rgb(opts.accent, "e6ff55"); gh = rgb(opts.ghost, "e54c1e");
var s = box.style;
s.setProperty("--ef-ktx-fg", "rgb(" + fg + ")"); s.setProperty("--ef-ktx-fg-rgb", fg.join(","));
s.setProperty("--ef-ktx-bg", "rgb(" + bg + ")"); s.setProperty("--ef-ktx-bg-rgb", bg.join(","));
s.setProperty("--ef-ktx-top", mix(bg, fg, 0.045)); s.setProperty("--ef-ktx-muted", mix(bg, fg, 0.62));
s.setProperty("--ef-ktx-acc", "rgb(" + ac + ")"); s.setProperty("--ef-ktx-acc-rgb", ac.join(","));
s.setProperty("--ef-ktx-ghost-rgb", gh.join(","));
s.setProperty("--ef-ktx-k", String(1 / opts.speed));
s.setProperty("--ef-ktx-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: "varfont", text: "A LIVING AXIS", words: "FORM, FUNCTION", speed: 1, size: 42, color: "#fbf8f1", accent: "#e6ff55", ghost: "#e54c1e", 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 || prev.words !== opts.words) build();
else { chroma(); if (rain && reduce) matrixStill(); }
}
update(o);
window.addEventListener("scroll", chroma, { passive: true });
window.addEventListener("resize", chroma);
if ("IntersectionObserver" in window) {
io = new IntersectionObserver(function (es) {
es.forEach(function (e) {
visible = e.isIntersecting;
box.classList.toggle("is-paused", !visible);
var vh = e.rootBounds ? e.rootBounds.height : window.innerHeight;
if (e.isIntersecting && (e.intersectionRatio >= 0.35 || e.boundingClientRect.height > vh * 0.8)) {
if (!inView) { inView = true; play(); }
} else if (!e.isIntersecting && inView) { inView = false; if (!reduce) box.classList.remove("is-in"); }
if (visible) { kick(); chroma(); }
});
}, { threshold: [0, 0.35] });
io.observe(box);
} else { inView = true; play(); }
if ("ResizeObserver" in window) {
ro = new ResizeObserver(function () { if (destroyed) return; box.classList.toggle("is-narrow", box.clientWidth < 520); chroma(); });
ro.observe(box);
}
function onVis() { if (!document.hidden) kick(); }
document.addEventListener("visibilitychange", onVis);
return {
update: update,
replay: replay,
destroy: function () {
destroyed = true; stopTimers(); cancelAnimationFrame(raf); raf = 0;
window.removeEventListener("scroll", chroma);
window.removeEventListener("resize", chroma);
document.removeEventListener("visibilitychange", onVis);
if (io) io.disconnect();
if (ro) ro.disconnect();
}
};
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"effect":"varfont","text":"A LIVING AXIS","words":"FORM, FUNCTION","speed":1,"size":42,"color":"#fbf8f1","accent":"#e6ff55","ghost":"#e54c1e","background":"#0c1e18","label":1});How it works
Variable weight animates font-variation-settings between weight 300 and 900 on a 4 s ease-in-out loop (the brand font's axis stops at 800). Matrix formation draws on a canvas, 560 by 150 pixels for a short word and wider for a longer one: each frame lays the background over the last at 35% so trails fade, drops one 22 px glyph per column, and once a column's drop reaches row four the matching letter of your word locks there in the accent colour, every other column. It steps 60 times a second on a clock, whatever the screen's refresh rate. Word morph stacks two words and swaps them every 2.2 s, each fading and blurring 6 px over 1 s; with more than two words it steps through them. Chromatic scroll measures how far the element's middle is from the middle of the screen and splits two coloured text-shadows apart by up to 8 px each way, closing at the centre. Gravity drop drops each letter 160 px with a fall, an 8 px overshoot and a 4 px bounce over 0.7 s, start times spread over 420 ms. Matrix and gravity play when about a third of the element is on screen and again each time it comes back; replay() plays them on demand.
Accessibility and performance
The text or the morph words are visually hidden text for screen readers; the matrix canvas has a label naming the word. With prefers-reduced-motion each study shows a still frame: the matrix as its locked word on a clean field, no colour split, the first morph word and the dropped letters already in place. Nothing needs keyboard input.
Only the matrix runs script per frame, about 25 to 40 glyphs on a small canvas, on a clock that pauses off screen and in hidden tabs; its pixel ratio is capped at 2. The other studies are CSS animations and transitions, and the scroll listener is passive and idle off screen.
| Section | Text |
|---|---|
| Techniques | Text animation, Reveal and intro, Stagger, Scroll-driven |
| Stack | Vanilla JS, CSS, Canvas |
| 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
Why does the variable weight stop getting bolder at the peak?
The loop asks for weight 900, as the original did with its font, but the brand font's weight axis ends at 800, so the last part of the swell holds at its heaviest. A font with a wider axis uses the full range.
How long can the matrix word be?
Any length: the canvas widens to fit every other column and shrinks to the stage width if needed. Short words of four to eight letters look the most like the original.
Can the colour split follow scroll speed instead of position?
This one follows position, as the original does. For a split that grows with scroll speed use the Scroll velocity skew element in the Library.