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 loops" for my site using the Enrichr Library element at https://enrichr.dev/elements/kinetic-type-loops.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/kinetic-type-loops.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: wave
- text: KEEP IT MOVING
- lead: DESIGNED FOR
- words: SPEED, CLARITY, CRAFT, RHYTHM
- 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-loops.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-ktl" data-fx="wave" data-label="1">
<div class="ef-ktl__head">
<span class="ef-ktl__num" aria-hidden="true">10</span>
<span class="ef-ktl__meta"><span class="ef-ktl__name">Wave</span><span class="ef-ktl__desc">A ripple runs continuously through the baseline.</span></span>
<span class="ef-ktl__tag">continuous</span>
</div>
<div class="ef-ktl__stage"><span class="ef-ktl__demo">KEEP IT MOVING</span></div>
</div>CSS
/* Enrichr Library · https://enrichr.dev */
.ef-ktl {
--ef-ktl-k: 1; --ef-ktl-size: 42;
--ef-ktl-fg: #fbf8f1; --ef-ktl-fg-rgb: 251, 248, 241; --ef-ktl-muted: #9ca39b;
--ef-ktl-bg: #0c1e18; --ef-ktl-bg-rgb: 12, 30, 24; --ef-ktl-top: #17281f;
--ef-ktl-acc: #e6ff55; --ef-ktl-acc-rgb: 230, 255, 85;
--ef-ktl-fs: min(calc(var(--ef-ktl-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-ktl-fg); font-family: 'Instrument Sans', system-ui, sans-serif;
background: radial-gradient(ellipse at 50% 120%, rgba(var(--ef-ktl-acc-rgb), .1), transparent 60%), linear-gradient(180deg, var(--ef-ktl-top), var(--ef-ktl-bg));
border: 1px solid rgba(var(--ef-ktl-acc-rgb), .2);
box-shadow: inset 0 1px 0 rgba(var(--ef-ktl-fg-rgb), .05);
}
.ef-ktl *, .ef-ktl *::before, .ef-ktl *::after { box-sizing: border-box; }
/* specimen label */
.ef-ktl .ef-ktl__head { position: relative; z-index: 2; display: flex; align-items: center; gap: 1rem; padding: 1.1rem 1.25rem 0; }
.ef-ktl[data-label="0"] .ef-ktl__head { display: none; }
.ef-ktl .ef-ktl__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-ktl-bg); background: var(--ef-ktl-acc);
box-shadow: 0 0 0 1px rgba(var(--ef-ktl-acc-rgb), .4), 0 4px 14px -4px rgba(var(--ef-ktl-acc-rgb), .55);
}
.ef-ktl .ef-ktl__meta { display: block; min-width: 0; text-align: left; }
.ef-ktl .ef-ktl__name { display: block; margin: 0 0 .15rem; font: 600 1.1rem/1.2 'Parkinsans', 'Space Grotesk', sans-serif; letter-spacing: .01em; color: var(--ef-ktl-fg); opacity: 1; }
.ef-ktl .ef-ktl__desc { display: block; margin: 0; font-size: .84rem; line-height: 1.45; color: var(--ef-ktl-muted); opacity: 1; }
.ef-ktl .ef-ktl__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-ktl-acc-rgb), .85); border: 1px solid rgba(var(--ef-ktl-acc-rgb), .35); background: rgba(var(--ef-ktl-acc-rgb), .06);
}
.ef-ktl.is-narrow .ef-ktl__tag, .ef-ktl.is-narrow .ef-ktl__desc { display: none; }
/* stage and type */
.ef-ktl .ef-ktl__stage { position: relative; z-index: 1; flex: 1; display: flex; align-items: center; justify-content: center; text-align: center; padding: 2rem 1.5rem; min-height: 12rem; }
.ef-ktl .ef-ktl__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-ktl .ef-ktl__demo {
display: inline-block; margin: 0; white-space: pre-wrap; color: var(--ef-ktl-fg); opacity: 1;
font-family: 'Parkinsans', 'Space Grotesk', sans-serif; font-weight: 500; font-size: var(--ef-ktl-fs); letter-spacing: .01em; line-height: 1.2;
}
.ef-ktl .ef-ktl__demo--small { font-family: 'Fira Code', ui-monospace, monospace; font-size: calc(var(--ef-ktl-fs) * .615); white-space: nowrap; }
.ef-ktl .ef-ktl__w { display: inline-block; white-space: nowrap; }
.ef-ktl .ef-ktl__ch { display: inline-block; will-change: transform, opacity, filter; }
/* 10 wave (14px at the default 42px size) */
.ef-ktl[data-fx="wave"].is-in .ef-ktl__ch { animation: ef-ktl-wave calc(1.8s * var(--ef-ktl-k)) ease-in-out infinite; animation-delay: calc(var(--i) * 60ms * var(--ef-ktl-k)); }
@keyframes ef-ktl-wave { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-.333em); } }
/* 11 shimmer sweep */
.ef-ktl[data-fx="shimmer"] .ef-ktl__demo {
background: linear-gradient(100deg, var(--ef-ktl-muted) 30%, var(--ef-ktl-acc) 45%, var(--ef-ktl-fg) 50%, var(--ef-ktl-acc) 55%, var(--ef-ktl-muted) 70%);
background-size: 250% 100%; -webkit-background-clip: text; background-clip: text; color: transparent;
}
.ef-ktl[data-fx="shimmer"].is-in .ef-ktl__demo { animation: ef-ktl-shimmer calc(3.2s * var(--ef-ktl-k)) linear infinite; }
@keyframes ef-ktl-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -100% 0; } }
/* 12 breathing glow */
.ef-ktl[data-fx="glow"].is-in .ef-ktl__demo { animation: ef-ktl-glow calc(2.6s * var(--ef-ktl-k)) ease-in-out infinite; }
@keyframes ef-ktl-glow {
0%, 100% { text-shadow: 0 0 4px rgba(var(--ef-ktl-acc-rgb), .15); }
50% { text-shadow: 0 0 26px rgba(var(--ef-ktl-acc-rgb), .8), 0 0 6px rgba(var(--ef-ktl-acc-rgb), .55); }
}
/* 13 liquid wobble */
.ef-ktl[data-fx="liquid"].is-in .ef-ktl__ch { animation: ef-ktl-liquid calc(2.4s * var(--ef-ktl-k)) ease-in-out infinite; }
@keyframes ef-ktl-liquid {
0%, 100% { transform: skewX(0) scaleY(1); }
25% { transform: skewX(-6deg) scaleY(1.08); }
50% { transform: skewX(0) scaleY(.94); }
75% { transform: skewX(6deg) scaleY(1.05); }
}
/* 14 eased marquee */
.ef-ktl[data-fx="marquee"] .ef-ktl__stage { justify-content: flex-start; }
.ef-ktl .ef-ktl__track { display: inline-flex; gap: 2.5rem; flex-shrink: 0; }
.ef-ktl[data-fx="marquee"].is-in .ef-ktl__track { animation: ef-ktl-marquee calc(7s * var(--ef-ktl-k)) cubic-bezier(.65, 0, .35, 1) infinite; }
@keyframes ef-ktl-marquee {
0% { transform: translateX(6%); }
45% { transform: translateX(-40%); }
50% { transform: translateX(-40%); }
95% { transform: translateX(6%); }
100% { transform: translateX(6%); }
}
/* 15 kinetic rotator */
.ef-ktl .ef-ktl__lead { font-size: 1rem; font-family: 'Instrument Sans', system-ui, sans-serif; color: var(--ef-ktl-fg); white-space: nowrap; }
.ef-ktl .ef-ktl__rot { display: inline-block; transition: transform calc(.5s * var(--ef-ktl-k)) cubic-bezier(.5, 0, .2, 1), opacity calc(.5s * var(--ef-ktl-k)) ease; }
/* off screen and reduced motion */
.ef-ktl.is-paused * { animation-play-state: paused !important; }
.ef-ktl.is-reduced *, .ef-ktl.is-reduced *::before, .ef-ktl.is-reduced *::after { transition: none !important; animation: none !important; }
.ef-ktl.is-reduced[data-fx="shimmer"] .ef-ktl__demo { background-position: 50% 0; }
.ef-ktl.is-reduced[data-fx="glow"] .ef-ktl__demo { text-shadow: 0 0 14px rgba(var(--ef-ktl-acc-rgb), .45); }
@media (prefers-reduced-motion: reduce) {
.ef-ktl *, .ef-ktl *::before, .ef-ktl *::after { transition: none !important; animation: none !important; }
}JavaScript
// Enrichr Library · https://enrichr.dev
// Kinetic type loops: the six looping studies from a kinetic type specimen book, one at a time (pick it with effect).
// Wave: each letter bobs 14 px up and back on a 1.8 s sine-like ease-in-out loop, starting 60 ms after the letter before.
// Shimmer sweep: a 250%-wide gradient (dim, accent, bright, accent, dim) slides across the clipped letters every 3.2 s.
// Breathing glow: an accent glow swells from 4 px to 26 px and back every 2.6 s. Liquid wobble: every letter skews and
// stretches through -6 deg / 108%, 94%, +6 deg / 105% on a 2.4 s loop. Eased marquee: three copies of the line glide left
// 46% of the track and back on a 7 s loop with cubic-bezier(.65,0,.35,1) and a short hold at each end. Kinetic rotator: a
// lead-in plus a word that changes every 2.2 s; the old word lifts 18 px and fades, the next drops in 0.4 s later.
// Loops run while the element is in view and stop when it scrolls away; speed scales every duration.
// Reduced motion shows one still frame of each study (the shimmer band centred, a steady glow, the first rotator word).
// mount(root, { effect (wave | shimmer | glow | liquid | marquee | rotator), text, lead (rotator lead-in), words (rotator
// words, comma separated), speed (1 = original timing), size (px), color, accent, background,
// label (1 shows the specimen label, 0 hides it) })
function mount(root, o) {
var reduce = !!(window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches);
var box = root.querySelector(".ef-ktl");
var numEl = box.querySelector(".ef-ktl__num"), nameEl = box.querySelector(".ef-ktl__name");
var descEl = box.querySelector(".ef-ktl__desc"), tagEl = box.querySelector(".ef-ktl__tag");
var stage = box.querySelector(".ef-ktl__stage");
var FX = {
wave: ["10", "Wave", "A ripple runs continuously through the baseline."],
shimmer: ["11", "Shimmer sweep", "A band of light passes over the letterforms."],
glow: ["12", "Breathing glow", "Light gathers and releases, slow as a held breath."],
liquid: ["13", "Liquid wobble", "Each letter behaves like it's suspended in syrup."],
marquee: ["14", "Eased marquee", "A ticker that accelerates, coasts, and settles, never linear."],
rotator: ["15", "Kinetic rotator", "One role, many words, cycling like a headline."]
};
var opts = {}, fx = "", text = "", demo = null, rot = null, words = [], idx = 0, inView = false, visible = true;
var io = null, ro = null, destroyed = false, timers = [], clock = 0, last = 0, raf = 0, frame = 0;
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 make(tag, cls, txt) { var e = document.createElement(tag); if (cls) e.className = cls; if (txt != null) e.textContent = txt; return e; }
function clean(v, d) { return String(v == null ? "" : v).replace(/\s+/g, " ").trim() || d; }
// one clock for the rotator's timers: 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 after(ms, fn) { var t = { at: clock + ms, every: 0, fn: fn }; timers.push(t); kick(); return t; }
function stopTimers() { timers.forEach(function (t) { t.dead = true; }); timers = []; cancelAnimationFrame(frame); }
function split() {
demo.textContent = "";
var word = null;
Array.from(text).forEach(function (ch, i) {
if (/\s/.test(ch)) { word = null; demo.appendChild(document.createTextNode(" ")); return; }
if (!word) { word = make("span", "ef-ktl__w"); demo.appendChild(word); }
var s = make("span", "ef-ktl__ch", ch);
s.style.setProperty("--i", i);
word.appendChild(s);
});
}
// the rotator, step for step as the original: set the word hidden 18 px low, then next frame show it; the browser
// retargets the still-running exit transition, so the new word settles from where the old one left (the very first word
// rises from 18 px below, because in the original page a layout read commits its hidden state first)
function show(first) {
rot.textContent = words[idx];
rot.style.opacity = 0;
rot.style.transform = "translateY(.429em)";
if (first) void rot.offsetWidth;
frame = requestAnimationFrame(function () { rot.style.opacity = 1; rot.style.transform = "translateY(0)"; });
}
function startRotator() {
stopTimers();
if (!rot) return;
idx = 0; show(!reduce);
if (reduce) return;
every(2200, function () {
rot.style.opacity = 0;
rot.style.transform = "translateY(-.429em)";
after(400, function () { idx = (idx + 1) % words.length; show(); });
});
}
function build() {
stopTimers();
fx = FX[opts.effect] ? opts.effect : "wave";
var f = FX[fx];
box.setAttribute("data-fx", fx);
numEl.textContent = f[0]; nameEl.textContent = f[1]; descEl.textContent = f[2]; tagEl.textContent = "continuous";
text = clean(opts.text, "KEEP IT MOVING");
words = String(opts.words || "").split(",").map(function (w) { return w.trim(); }).filter(Boolean);
if (!words.length) words = ["SPEED", "CLARITY", "CRAFT", "RHYTHM"];
stage.textContent = ""; rot = null;
var lead = clean(opts.lead, "");
stage.appendChild(make("span", "ef-ktl__sr", fx === "rotator" ? (lead ? lead + " " : "") + words.join(", ") : text));
var holder;
if (fx === "marquee") {
holder = make("span", "ef-ktl__track"); holder.setAttribute("aria-hidden", "true");
for (var i = 0; i < 3; i++) holder.appendChild(make("span", "ef-ktl__demo ef-ktl__demo--small", text));
} else if (fx === "rotator") {
holder = make("span"); holder.setAttribute("aria-hidden", "true");
if (lead) holder.appendChild(make("span", "ef-ktl__lead", lead + " "));
rot = make("span", "ef-ktl__demo ef-ktl__rot");
holder.appendChild(rot);
} else {
holder = demo = make("span", "ef-ktl__demo"); demo.setAttribute("aria-hidden", "true");
if (fx === "wave" || fx === "liquid") split(); else demo.textContent = text;
}
stage.appendChild(holder);
if (reduce) box.classList.add("is-reduced");
if (rot) startRotator();
}
function paint() {
var fg = rgb(opts.color, "fbf8f1"), bg = rgb(opts.background, "0c1e18"), ac = rgb(opts.accent, "e6ff55"), s = box.style;
s.setProperty("--ef-ktl-fg", "rgb(" + fg + ")"); s.setProperty("--ef-ktl-fg-rgb", fg.join(","));
s.setProperty("--ef-ktl-bg", "rgb(" + bg + ")"); s.setProperty("--ef-ktl-bg-rgb", bg.join(","));
s.setProperty("--ef-ktl-top", mix(bg, fg, 0.045)); s.setProperty("--ef-ktl-muted", mix(bg, fg, 0.62));
s.setProperty("--ef-ktl-acc", "rgb(" + ac + ")"); s.setProperty("--ef-ktl-acc-rgb", ac.join(","));
s.setProperty("--ef-ktl-k", String(1 / opts.speed));
s.setProperty("--ef-ktl-size", String(opts.size));
box.setAttribute("data-label", opts.label ? "1" : "0");
}
function update(partial) {
var prev = opts;
opts = Object.assign({ effect: "wave", text: "KEEP IT MOVING", lead: "DESIGNED FOR", words: "SPEED, CLARITY, CRAFT, RHYTHM", 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 || prev.lead !== opts.lead || prev.words !== opts.words) build();
}
update(o);
// loops start when at least a third of the element is in view (or it fills the screen) and stop once it has left
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)) { inView = true; if (!reduce) box.classList.add("is-in"); }
else if (!e.isIntersecting && inView) { inView = false; box.classList.remove("is-in"); }
if (visible) kick();
});
}, { threshold: [0, 0.35] });
io.observe(box);
} else if (!reduce) box.classList.add("is-in");
if ("ResizeObserver" in window) {
ro = new ResizeObserver(function () { if (!destroyed) box.classList.toggle("is-narrow", box.clientWidth < 520); });
ro.observe(box);
}
function onVis() { if (!document.hidden) kick(); }
document.addEventListener("visibilitychange", onVis);
return {
update: update,
destroy: function () {
destroyed = true; stopTimers(); cancelAnimationFrame(raf); raf = 0;
if (io) io.disconnect();
if (ro) ro.disconnect();
document.removeEventListener("visibilitychange", onVis);
}
};
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"effect":"wave","text":"KEEP IT MOVING","lead":"DESIGNED FOR","words":"SPEED, CLARITY, CRAFT, RHYTHM","speed":1,"size":42,"color":"#fbf8f1","accent":"#e6ff55","background":"#0c1e18","label":1});How it works
Every study is a CSS keyframe loop with the original timing. Wave lifts each letter 14 px and back over 1.8 s, each one starting 60 ms after the last. Shimmer clips a 250%-wide gradient (dim, accent, bright, accent, dim) to the letters and slides it across every 3.2 s. Breathing glow swells an accent text-shadow from 4 px to 26 px and back every 2.6 s. Liquid wobble skews and stretches every letter through four poses on a 2.4 s loop. The eased marquee moves three copies of the line 46% of the track and back over 7 s on cubic-bezier(.65,0,.35,1), holding briefly at each end, so it speeds up, coasts and settles. The rotator changes the word every 2.2 s: the old word lifts and fades over 0.5 s, and 0.4 s in the next word is set and shown; because that change interrupts the running transition, the new word settles in from where the old one left, just as in the original. Loops start when about a third of the element is on screen and stop once it has gone; speed divides every duration. In the original pen the wave and liquid letters were never split into spans, so those two plates sat still; here they are split, so their keyframes play as written.
Accessibility and performance
The line (or, for the rotator, the lead-in and every word) is in the element as visually hidden text, and the animated copy is hidden from screen readers, so nothing is announced on each change. With prefers-reduced-motion every study shows one still frame: the shimmer band centred, a steady glow, the first rotator word. The loops are slow and nothing flashes. No keyboard input is needed.
The loops are CSS animations of transform, text-shadow and background-position, so they run without JavaScript per frame. They stop when the element leaves the screen. Only the rotator uses a timer, on a requestAnimationFrame clock that pauses off screen and in hidden tabs.
| Section | Text |
|---|---|
| Techniques | Text animation, Stagger |
| 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
Can I use the rotator with my own words?
Yes. Type the lead-in and a comma-separated list of words. They cycle in order and loop forever.
Why does the shimmer look flat in a light theme?
The band runs from a dimmed text colour through the accent to the full text colour. On a light background pick a dark text colour and a strong accent so the band still reads.
Can the marquee run endlessly in one direction?
This one deliberately eases back and forth. For an endless one-way ticker use the Marquee ticker element in the Library.