Updated 2026-10-14. 12 effects.
A text reveal animation hides text behind a mask, blur or scramble and uncovers it, often as the reader scrolls. The cleanest versions animate a clip-path or mask with CSS, or link the reveal to scroll with animation-timeline, falling back to IntersectionObserver where that is unsupported. Every reveal here keeps the words real and readable throughout.
Scroll-linked reveals suit a single statement paragraph that you want people to read slowly. Mask wipes and line un-crops suit headlines that arrive once. The scramble decodes letters into place and fits technical or mysterious brands; keep it short so the message is not delayed.
A reveal should never make text unreadable for longer than a moment, and it must never hide text from people who skip the animation. Each effect shows the finished text immediately for reduced motion and keeps the sentence available to screen readers from the first moment.
| Effect | Built with | Size (gzipped) | Running cost | Browser support |
|---|---|---|---|---|
| Calm Letter Decode Scramble | CSS only | 2.2 KB | A 70 ms timer rewrites a few dozen small spans; no canvas or layout work, and it stops off screen. | - |
| Dawn Blur Fade Word by Word | CSS only | 1.6 KB | Blur filters on about ten words while they enter; filters cost more than transforms, so keep it to one headline. | - |
| Ferry Departure Board Rows Scramble Into Place | CSS and a small script | 2.7 KB | Only tiles whose letter changes are rewritten, for under a second per update, and the timer stops when the board leaves the screen. | All current browsers |
| Hairline Rule Line Rise | CSS only | 1.4 KB | A transform on each line and a scaleX on each rule; almost free to run, paused off screen. | - |
| Ink Bleed on Paper Reveal | CSS only | 1.9 KB | An animated mask and blur per word plus a static SVG noise filter; moderate cost, best on one headline. | - |
| Nav Links Rescramble On Hover And Focus | CSS and a small script | 2.4 KB | Each scramble rewrites one short label for about half a second, then nothing runs until the next hover or focus. | All current browsers |
| Pointer Spotlight Hidden Text | CSS only | 2 KB | One frame loop writing two custom properties to an animated mask; light, and stopped off screen or when opened. | - |
| Rotating Headline Scrambles Between Three Phrases | CSS and a small script | 2.2 KB | One short frame loop rewrites a single line for under a second per change, then a timer waits; both stop off screen. | All current browsers |
| Scanner Light Bar Sweep | CSS only | 1.6 KB | One clip-path animation and one transformed bar; light enough for any page, paused off screen. | - |
| Scroll Linked Words Brighten as You Read | CSS only | 2.2 KB | Opacity on a few dozen spans driven by the scroll timeline; one light frame loop for the demo scroll, paused off screen. | - |
| Terracotta Curtain Wipe Headline | CSS only | 1.6 KB | Two transform and opacity animations per line on the compositor; the script only pauses them off screen. | - |
| Weighted Letter Drop Headline | CSS only | 1.7 KB | Transform and opacity on about twenty letter spans; compositor only, paused off screen. | - |
A headline that arrives as a row of soft marks (dots, dashes, tildes) and settles into real letters one by one from the left, finishing with a slow amber caret. The monospace face keeps every slot the same width, and a hidden copy of the finished line holds the box size, so nothing below it moves while the letters resolve. It suits archive, data and research sites where a decoding feel fits the subject.
<section class="lds-root" data-lds>
<div class="lds-grain" aria-hidden="true"></div>
<div class="lds-wrap">
<p class="lds-eyebrow">Tidewater Map Archive</p>
<h1 class="lds-line">
<span class="lds-sr">Every coastline, decoded by hand.</span>
<span class="lds-stack" aria-hidden="true">
<span class="lds-ghost">Every coastline, decoded by hand.<span class="lds-caret"></span></span>
<span class="lds-live"></span>
</span>
</h1>
<p class="lds-copy">Four hundred hand-drawn charts of the northern shore, scanned at full size and transcribed line by line.</p>
<div class="lds-actions">
<a href="#" class="lds-btn">Browse the charts</a>
<button type="button" class="lds-replay">Replay</button>
</div>
</div>
</section>
<style>
/* Change these: --lds-bg (background), --lds-ink (settled letters), --lds-calm (letters still decoding), --lds-accent (caret and label). */
.lds-root { --lds-bg: #0d1a1c; --lds-ink: #efe6d2; --lds-calm: #7fa39c; --lds-accent: #d9a45b;
position: relative; overflow: hidden; padding: 80px 20px; color: var(--lds-ink);
background: radial-gradient(120% 90% at 20% 0%, #16302f 0%, var(--lds-bg) 55%, #0a1315 100%); }
.lds-grain { position: absolute; inset: 0; opacity: .5; pointer-events: none;
background-image: radial-gradient(rgba(239,230,210,.05) 1px, transparent 1px); background-size: 3px 3px; }
.lds-wrap { position: relative; max-width: 56rem; margin: 0 auto; }
.lds-eyebrow { margin: 0; font-size: .875rem; font-weight: 500; letter-spacing: .2em; text-transform: uppercase; color: var(--lds-accent); }
.lds-line { margin: 20px 0 0; font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-weight: 600;
font-size: clamp(1.7rem, 5.4vw, 3.6rem); line-height: 1.15; letter-spacing: -.01em; }
.lds-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.lds-stack { display: grid; }
.lds-stack > * { grid-area: 1 / 1; }
.lds-ghost { visibility: hidden; }
.lds-word { white-space: nowrap; }
.lds-ch { transition: color .35s ease, opacity .35s ease; }
.lds-ch[data-done="false"] { color: var(--lds-calm); opacity: .55; }
@keyframes lds-caret { 50% { opacity: 0 } }
.lds-caret { display: inline-block; width: .5ch; height: .9em; margin-left: .15ch; vertical-align: -.08em;
background: var(--lds-accent); animation: lds-caret 1.1s steps(1) infinite; }
.lds-copy { max-width: 36rem; margin: 24px 0 0; font-size: 1.0625rem; line-height: 1.65; color: #c9c2b0; }
.lds-actions { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 36px; }
.lds-btn, .lds-replay { min-height: 44px; display: inline-flex; align-items: center; border-radius: 999px; font: 600 .875rem/1 system-ui, sans-serif; }
.lds-btn { padding: 0 24px; background: var(--lds-ink); color: var(--lds-bg); text-decoration: none; }
.lds-replay { padding: 0 16px; background: none; border: 0; color: #c9c2b0; cursor: pointer; }
.lds-btn:focus-visible, .lds-replay:focus-visible { outline: 2px solid var(--lds-accent); outline-offset: 3px; }
@media (min-width: 640px) { .lds-root { padding: 112px 40px; } }
@media (prefers-reduced-motion: reduce) { .lds-caret { animation: none; } .lds-ch { transition: none; } }
</style>
<script>
(function () {
var root = document.querySelector("[data-lds]");
if (!root) return;
var TEXT = root.querySelector(".lds-sr").textContent;
var CALM = "ยท:-=~+/."; // characters shown while a letter is still decoding
var STEP_MS = 34; // gap between one letter settling and the next
var FLICKER_MS = 70; // how often an unsettled letter changes
var HOLD_MS = 5200; // rest time before the line decodes again
var live = root.querySelector(".lds-live");
var cells = [];
TEXT.split(" ").forEach(function (word, w) {
if (w > 0) { live.appendChild(document.createTextNode(" ")); cells.push(null); }
var group = document.createElement("span");
group.className = "lds-word";
for (var i = 0; i < word.length; i++) {
var c = document.createElement("span");
c.className = "lds-ch";
group.appendChild(c);
cells.push(c);
}
live.appendChild(group);
});
var caret = document.createElement("span");
caret.className = "lds-caret";
live.appendChild(caret);
function draw(t) {
for (var i = 0; i < cells.length; i++) {
if (!cells[i]) continue;
var done = t > 160 + i * STEP_MS;
cells[i].textContent = done ? TEXT[i] : CALM[Math.floor(Math.random() * CALM.length)];
cells[i].setAttribute("data-done", done ? "true" : "false");
}
}
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)");
var start = performance.now(), timer = 0, onScreen = true;
function tick() {
var t = performance.now() - start;
if (t > 160 + TEXT.length * STEP_MS + HOLD_MS) start = performance.now();
draw(performance.now() - start);
}
function update() {
clearInterval(timer); timer = 0;
if (reduce.matches) { draw(Infinity); return; }
if (onScreen) timer = setInterval(tick, FLICKER_MS);
}
draw(0);
var io = new IntersectionObserver(function (e) { onScreen = e[0].isIntersecting; update(); });
io.observe(root);
reduce.addEventListener("change", update);
root.querySelector(".lds-replay").addEventListener("click", function () { start = performance.now(); update(); });
update();
})();
</script>
With reduced motion: The headline appears fully decoded with a steady caret and no timer running.
Running cost: A 70 ms timer rewrites a few dozen small spans; no canvas or layout work, and it stops off screen.
Full page for Calm Letter Decode Scramble
Words of a centred serif headline drift up out of a soft focus one after another over a pale sunrise gradient, then the subline follows. After a long rest they dissolve upward together and the line forms again. Fits wellness, travel and hospitality heroes where the motion should feel slow and warm.
<section class="bfw-root" data-bfw>
<div class="bfw-wrap">
<p class="bfw-label">Low Tide Yoga</p>
<h1 class="bfw-h"><span class="bfw-sr">Arrive before the light does, and leave a little lighter.</span><span class="bfw-words" aria-hidden="true"></span></h1>
<p class="bfw-sub">Sunrise sessions on the east dock, six mornings a week.</p>
<a href="#" class="bfw-btn">Book a first morning</a>
</div>
</section>
<style>
/* Change these: --bfw-ink (text), --bfw-blur (starting blur), --bfw-cycle (loop length), --bfw-stagger (delay between words). The sky lives in the background gradients. */
.bfw-root { --bfw-ink: #1d2433; --bfw-blur: 14px; --bfw-cycle: 9s; --bfw-stagger: 85ms;
color: var(--bfw-ink); text-align: center; padding: 96px 20px; font-family: system-ui, sans-serif; background:
radial-gradient(60% 55% at 50% 108%, rgba(255,196,150,.85), transparent 70%),
radial-gradient(80% 60% at 15% 0%, rgba(201,214,232,.9), transparent 70%),
linear-gradient(180deg, #dfe5ee 0%, #f1e2d6 62%, #f6d7c0 100%); }
@media (min-width: 640px) { .bfw-root { padding: 112px 40px; } }
.bfw-wrap { max-width: 56rem; margin: 0 auto; }
.bfw-label { margin: 0; font-size: .875rem; font-weight: 600; letter-spacing: .24em; text-transform: uppercase; color: #7a4a33; }
.bfw-h { font-family: Georgia, "Iowan Old Style", "Times New Roman", serif; font-weight: 400;
font-size: clamp(2rem, 5.6vw, 4.1rem); line-height: 1.12; letter-spacing: -.02em; margin: 24px auto 0; max-width: 15em; }
.bfw-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.bfw-w { display: inline-block; white-space: pre; will-change: filter, opacity, transform;
animation: bfw-in var(--bfw-cycle) cubic-bezier(.22,.8,.3,1) infinite both; animation-delay: calc(var(--i) * var(--bfw-stagger)); }
@keyframes bfw-in {
0% { opacity: 0; filter: blur(var(--bfw-blur)); transform: translateY(.28em) }
11%, 86% { opacity: 1; filter: blur(0); transform: none }
95%, 100% { opacity: 0; filter: blur(10px); transform: translateY(-.12em) } }
.bfw-sub { margin: 24px auto 0; max-width: 32rem; font-size: 1.0625rem; line-height: 1.65; color: #444b5a;
animation: bfw-in var(--bfw-cycle) cubic-bezier(.22,.8,.3,1) infinite both; animation-delay: 900ms; }
.bfw-btn { margin-top: 36px; min-height: 44px; display: inline-flex; align-items: center; padding: 0 28px; border-radius: 999px;
background: var(--bfw-ink); color: #f6efe8; font-size: .875rem; font-weight: 600; text-decoration: none; }
.bfw-btn:focus-visible { outline: 2px solid var(--bfw-ink); outline-offset: 3px; }
.bfw-root[data-paused] .bfw-w, .bfw-root[data-paused] .bfw-sub { animation-play-state: paused; }
@media (prefers-reduced-motion: reduce) { .bfw-w, .bfw-sub { animation: none; } }
</style>
<script>
(function () {
var root = document.querySelector("[data-bfw]");
if (!root) return;
// Build one span per word from the screen reader copy.
var holder = root.querySelector(".bfw-words");
var words = root.querySelector(".bfw-sr").textContent.split(" ");
words.forEach(function (w, i) {
var s = document.createElement("span");
s.className = "bfw-w";
s.style.setProperty("--i", i);
s.textContent = i < words.length - 1 ? w + " " : w;
holder.appendChild(s);
});
// Pause the loop while the section is off screen.
new IntersectionObserver(function (e) {
root.toggleAttribute("data-paused", !e[0].isIntersecting);
}).observe(root);
})();
</script>
With reduced motion: The headline and subline appear sharp and still with no blur or fade.
Running cost: Blur filters on about ten words while they enter; filters cost more than transforms, so keep it to one headline.
A navy timetable board for a small ferry operator where every letter sits on its own fixed tile. When the board appears, and again each time it moves on to later sailings, the tiles that need a new letter run through noise and land left to right, row after row. Hovering a row flips it again. Use it for timetables, opening hours or an events line-up.
<section class="w3dbd">
<div class="w3dbd-wrap">
<div class="w3dbd-head">
<div>
<p class="w3dbd-kicker">Harbour Line Ferries</p>
<h2>Departures from the north pier</h2>
</div>
<button type="button" class="w3dbd-next">Later sailings</button>
</div>
<div class="w3dbd-board">
<table>
<caption class="w3dbd-sr">Next four sailings</caption>
<thead><tr><th scope="col">Time</th><th scope="col">Destination</th><th scope="col">Status</th></tr></thead>
<tbody></tbody>
</table>
</div>
<p class="w3dbd-note">Foot passengers board from gate A. Cars queue in lanes 1 to 4 from forty minutes before sailing.</p>
</div>
</section>
<style>
/* Change these: --w3dbd-bg (section), --w3dbd-tile (letter tiles), --w3dbd-ink (letters), --w3dbd-cell (tile width). */
.w3dbd { --w3dbd-bg: #0e1a2b; --w3dbd-tile: #1a2a42; --w3dbd-ink: #f3e6c4; --w3dbd-cell: 1.3em;
background: radial-gradient(120% 80% at 50% 0%, #17283f, var(--w3dbd-bg) 70%); color: #e8edf4; font-family: ui-sans-serif, system-ui, sans-serif; padding: 72px 20px; }
.w3dbd-wrap { max-width: 56rem; margin: 0 auto; }
.w3dbd-head { display: flex; flex-wrap: wrap; gap: 16px; align-items: end; justify-content: space-between; }
.w3dbd-kicker { margin: 0; font-size: 12px; font-weight: 600; letter-spacing: .22em; text-transform: uppercase; color: #e0b862; }
.w3dbd h2 { margin: 10px 0 0; font-size: clamp(26px, 3.6vw, 36px); font-weight: 600; letter-spacing: -.02em; }
.w3dbd-next { min-height: 44px; padding: 0 20px; border: 1px solid #3a4d6b; border-radius: 999px; background: transparent; color: #e8edf4; font: 500 14px ui-sans-serif, system-ui, sans-serif; cursor: pointer; }
.w3dbd-next:hover { background: #1a2a42; }
.w3dbd button:focus-visible { outline: 2px solid #e0b862; outline-offset: 3px; }
.w3dbd-board { margin-top: 28px; padding: 18px; border-radius: 14px; background: #0a1422; box-shadow: inset 0 1px 0 rgba(255,255,255,.05), 0 24px 48px -24px rgba(0,0,0,.7); font-size: clamp(13px, 2.1vw, 19px); }
.w3dbd table { width: 100%; border-collapse: collapse; }
.w3dbd th { padding: 0 0 10px; text-align: left; font: 600 11px ui-sans-serif, system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #93a3bb; }
.w3dbd td { padding: 5px 0; vertical-align: top; }
.w3dbd td + td, .w3dbd th + th { padding-left: .9em; }
@media (min-width: 641px) { .w3dbd td:not(:last-child), .w3dbd th:not(:last-child) { width: 1%; } }
.w3dbd-tiles { display: flex; gap: 2px; }
.w3dbd-tiles span { position: relative; width: var(--w3dbd-cell); height: 1.75em; display: flex; align-items: center; justify-content: center;
border-radius: 3px; background: linear-gradient(#22344f, var(--w3dbd-tile) 49%, #0c1626 50%, var(--w3dbd-tile) 52%); color: var(--w3dbd-ink);
font: 700 1em/1 ui-monospace, "SF Mono", Menlo, Consolas, monospace; }
.w3dbd-tiles span[data-on] { color: #9fb2cc; }
.w3dbd-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.w3dbd-note { max-width: 36rem; margin: 22px 0 0; font-size: 15px; line-height: 1.6; color: #a9b6c9; }
@media (max-width: 640px) {
.w3dbd-board { padding: 14px 12px; font-size: 14px; }
.w3dbd thead tr { display: none; }
.w3dbd tbody tr { display: grid; grid-template-columns: auto 1fr; column-gap: .9em; padding-bottom: 10px; }
.w3dbd td:nth-child(2) { grid-column: 1 / 3; grid-row: 1; padding-left: 0; }
.w3dbd td:last-child { padding-top: 0; }
}
</style>
<script>
(function () {
/* Change these: the sailings, and the timing. Widths are fixed letter counts per column. */
var SAILINGS = "09:10|Skerrow Isle|Boarding;09:45|Port Annet|On time;10:20|Holm Bay|On time;11:05|Calder Quay|Delayed;11:40|Linn Point|On time;12:15|Easter Ness|On time"
.split(";").map(function (s) { return s.split("|"); });
var WIDTHS = [5, 12, 8];
var GLYPHS = "ABCDEFGHKLMNPRSTVWXYZ0123456789";
var STEP = 40, LEAD = 220, ADVANCE = 6000; /* ms per letter, noise before settling, auto advance */
var root = document.querySelector(".w3dbd");
if (!root) return;
var body = root.querySelector("tbody"), fields = [], k = 0, auto = 0, live = false;
function mk(c) { var e = document.createElement("span"); e.className = c; return e; }
var reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
for (var r = 0; r < 4; r++) {
var tr = body.appendChild(document.createElement("tr"));
WIDTHS.forEach(function (w) {
var td = tr.appendChild(document.createElement("td"));
var sr = td.appendChild(mk("w3dbd-sr")), box = td.appendChild(mk("w3dbd-tiles")), cells = [];
box.setAttribute("aria-hidden", "true");
for (var i = 0; i < w; i++) cells.push(box.appendChild(mk("")));
fields.push({ sr: sr, cells: cells, f: 0 });
});
}
function set(fd, text, delay) {
var to = (text + " ").slice(0, fd.cells.length).toUpperCase(), t0 = performance.now() + delay;
fd.sr.textContent = text;
cancelAnimationFrame(fd.f);
(function frame(now) {
var t = now - t0, busy = false;
fd.cells.forEach(function (c, i) {
if (c.textContent === to[i] && !c.hasAttribute("data-on")) return;
if (t > LEAD + i * STEP) { c.textContent = to[i]; c.removeAttribute("data-on"); }
else if (t > 0) { busy = true; c.setAttribute("data-on", ""); c.textContent = GLYPHS[Math.random() * GLYPHS.length | 0]; }
else busy = true;
});
fd.f = busy ? requestAnimationFrame(frame) : 0;
})(t0);
}
function show() {
fields.forEach(function (fd, n) {
var row = n / 3 | 0;
set(fd, SAILINGS[(k + row) % SAILINGS.length][n % 3], row * 120 + (n % 3) * 60);
});
}
function next() { k = (k + 1) % SAILINGS.length; show(); }
root.querySelector(".w3dbd-next").addEventListener("click", function () { live = true; clearInterval(auto); next(); });
if (reduce) { STEP = LEAD = 0; return show(); }
/* Hovering a row flips it again. */
[].forEach.call(body.rows, function (tr, row) {
tr.addEventListener("pointerenter", function () {
fields.slice(row * 3, row * 3 + 3).forEach(function (fd) {
fd.cells.forEach(function (c) { c.textContent = ""; });
set(fd, fd.sr.textContent, 0);
});
});
});
var seen = false;
new IntersectionObserver(function (e) {
clearInterval(auto);
if (!e[0].isIntersecting) return;
if (!seen) { seen = true; show(); }
if (!live) auto = setInterval(next, ADVANCE);
}).observe(root);
})();
</script>
With reduced motion: The board shows its four sailings as still tiles, and the button swaps in later sailings at once without any shuffling.
Running cost: Only tiles whose letter changes are rewritten, for under a second per update, and the timer stops when the board leaves the screen.
A one pixel rule draws under each line of a large sans headline, then the words rise up out of it as if the line had been cropped from below. The lines follow each other in order and retract the same way. Built for architecture, editorial and studio sites on a white page.
<section class="hlu-root" data-hlu>
<div class="hlu-grid">
<div>
<p class="hlu-label">Nordquist Architects</p>
<h1 class="hlu-h">
<span class="hlu-line" style="--i:0"><span class="hlu-text">We draw buildings</span></span>
<span class="hlu-line" style="--i:1"><span class="hlu-text">that settle quietly</span></span>
<span class="hlu-line" style="--i:2"><span class="hlu-text">into their streets.</span></span>
</h1>
</div>
<div>
<p class="hlu-copy">Houses, libraries and small civic halls in timber and brick, designed to sit well beside what was there first.</p>
<a href="#" class="hlu-link">View selected work</a>
</div>
</div>
</section>
<style>
/* Change these: --hlu-ink (text), --hlu-rule (hairline colour), --hlu-cycle (loop length), --hlu-stagger (delay between lines). */
.hlu-root { --hlu-ink: #0f172a; --hlu-rule: #94a3b8; --hlu-cycle: 8s; --hlu-stagger: 160ms;
background: #ffffff; color: var(--hlu-ink); padding: 80px 20px; font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
@media (min-width: 640px) { .hlu-root { padding: 96px 40px; } }
.hlu-grid { max-width: 64rem; margin: 0 auto; display: grid; gap: 40px; }
@media (min-width: 1024px) { .hlu-grid { grid-template-columns: 1fr 260px; align-items: end; } }
.hlu-label { margin: 0; font-size: .75rem; font-weight: 600; letter-spacing: .22em; text-transform: uppercase; color: #475569; }
.hlu-h { font-weight: 500; font-size: clamp(2.1rem, 6.6vw, 4.9rem); line-height: 1.08; letter-spacing: -.04em; margin: 24px 0 0; }
.hlu-line { position: relative; display: block; overflow: hidden; padding: .04em 0 .1em; }
.hlu-line::after { content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 1px; background: var(--hlu-rule);
transform-origin: 0 50%; animation: hlu-rule var(--hlu-cycle) cubic-bezier(.65,0,.25,1) infinite both; animation-delay: calc(var(--i) * var(--hlu-stagger)); }
.hlu-text { display: block; animation: hlu-rise var(--hlu-cycle) cubic-bezier(.2,.75,.25,1) infinite both; animation-delay: calc(var(--i) * var(--hlu-stagger)); }
@keyframes hlu-rule { 0% { transform: scaleX(0) } 7%, 90% { transform: scaleX(1) } 98%, 100% { transform: scaleX(0); transform-origin: 100% 50% } }
@keyframes hlu-rise { 0%, 5% { transform: translateY(105%) } 15%, 86% { transform: none } 93%, 100% { transform: translateY(105%) } }
.hlu-copy { margin: 0; font-size: 1rem; line-height: 1.65; color: #475569; }
.hlu-link { margin-top: 24px; min-height: 44px; display: inline-flex; align-items: center; border-bottom: 1px solid var(--hlu-ink);
color: var(--hlu-ink); font-size: .875rem; font-weight: 600; text-decoration: none; }
.hlu-link:focus-visible { outline: 2px solid var(--hlu-ink); outline-offset: 4px; }
.hlu-root[data-paused] .hlu-text, .hlu-root[data-paused] .hlu-line::after { animation-play-state: paused; }
@media (prefers-reduced-motion: reduce) { .hlu-text, .hlu-line::after { animation: none; } }
</style>
<script>
(function () {
// Pause the loop while the section is off screen.
var root = document.querySelector("[data-hlu]");
if (!root) return;
new IntersectionObserver(function (e) {
root.toggleAttribute("data-paused", !e[0].isIntersecting);
}).observe(root);
})();
</script>
With reduced motion: All three lines rest in place with their hairlines fully drawn.
Running cost: A transform on each line and a scaleX on each rule; almost free to run, paused off screen.
Italic serif words soak into grainy cream paper one after another, each spreading outward from a point inside the word while a slight blur dries away. A static fibre filter roughens the letter edges so the ink feels absorbed, not printed. Fits stationery, bookbinding, wine and other handmade brands.
<section class="ibp-root" data-ibp>
<svg class="ibp-paper" aria-hidden="true" focusable="false">
<filter id="ibp-grain">
<feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch" />
<feColorMatrix values="0 0 0 0 0.45 0 0 0 0 0.38 0 0 0 0 0.28 0 0 0 0.22 0" />
</filter>
<filter id="ibp-fibre">
<feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" seed="4" result="n" />
<feDisplacementMap in="SourceGraphic" in2="n" scale="2.4" />
</filter>
<rect width="100%" height="100%" filter="url(#ibp-grain)" />
</svg>
<div class="ibp-wrap">
<p class="ibp-label">Marrow and Quill Stationers</p>
<h1 class="ibp-h"><span class="ibp-sr">Letters that dry slowly and keep for a lifetime.</span><span class="ibp-words" aria-hidden="true"></span></h1>
<p class="ibp-copy">Iron gall and walnut inks mixed in small batches, with cotton paper heavy enough to hold a wet nib without feathering.</p>
<a href="#" class="ibp-btn">Order a sample set</a>
</div>
</section>
<style>
/* Change these: --ibp-paper (background), --ibp-ink (text), --ibp-cycle (loop length), --ibp-stagger (gap between words). Edge roughness is the scale="2.4" on the fibre filter. */
@property --ibp-r { syntax: "<percentage>"; inherits: false; initial-value: 0%; }
.ibp-root { --ibp-paper: #f4efe4; --ibp-ink: #1b1f2a; --ibp-cycle: 9s; --ibp-stagger: 120ms;
position: relative; overflow: hidden; background: var(--ibp-paper); color: var(--ibp-ink); padding: 80px 20px; font-family: system-ui, sans-serif; }
@media (min-width: 640px) { .ibp-root { padding: 96px 40px; } }
.ibp-paper { position: absolute; inset: 0; width: 100%; height: 100%; opacity: .55; pointer-events: none; }
.ibp-wrap { position: relative; max-width: 56rem; margin: 0 auto; }
.ibp-label { margin: 0; font-size: .875rem; font-weight: 600; letter-spacing: .2em; text-transform: uppercase; color: #7b3f2a; }
.ibp-h { font-family: Georgia, "Iowan Old Style", "Times New Roman", serif; font-weight: 400; font-style: italic;
font-size: clamp(2.1rem, 6.4vw, 4.6rem); line-height: 1.1; letter-spacing: -.015em; margin: 24px 0 0; filter: url(#ibp-fibre); }
.ibp-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.ibp-w { display: inline-block; white-space: pre; padding: 0 .04em;
-webkit-mask-image: radial-gradient(circle at 30% 60%, #000 var(--ibp-r), transparent calc(var(--ibp-r) + 22%));
mask-image: radial-gradient(circle at 30% 60%, #000 var(--ibp-r), transparent calc(var(--ibp-r) + 22%));
animation: ibp-bleed var(--ibp-cycle) cubic-bezier(.3,.6,.2,1) infinite both; animation-delay: calc(var(--i) * var(--ibp-stagger)); }
@keyframes ibp-bleed {
0% { --ibp-r: 0%; filter: blur(3px); opacity: .2 }
12% { --ibp-r: 130%; filter: blur(0); opacity: 1 }
86% { --ibp-r: 130%; filter: blur(0); opacity: 1 }
96%, 100% { --ibp-r: 130%; filter: blur(2px); opacity: 0 } }
.ibp-copy { margin: 28px 0 0; max-width: 32rem; font-size: 1.0625rem; line-height: 1.65; color: #4a4438; }
.ibp-btn { margin-top: 32px; min-height: 44px; display: inline-flex; align-items: center; padding: 0 24px; border-radius: 2px;
background: var(--ibp-ink); color: var(--ibp-paper); font-size: .875rem; font-weight: 600; text-decoration: none; }
.ibp-btn:focus-visible { outline: 2px solid var(--ibp-ink); outline-offset: 3px; }
.ibp-root[data-paused] .ibp-w { animation-play-state: paused; }
@media (prefers-reduced-motion: reduce) { .ibp-w { animation: none; --ibp-r: 130%; } }
</style>
<script>
(function () {
var root = document.querySelector("[data-ibp]");
if (!root) return;
// One span per word, built from the screen reader copy.
var holder = root.querySelector(".ibp-words");
var words = root.querySelector(".ibp-sr").textContent.split(" ");
words.forEach(function (w, i) {
var s = document.createElement("span");
s.className = "ibp-w";
s.style.setProperty("--i", i);
s.textContent = i < words.length - 1 ? w + " " : w;
holder.appendChild(s);
});
// Pause the loop while the section is off screen.
new IntersectionObserver(function (e) {
root.toggleAttribute("data-paused", !e[0].isIntersecting);
}).observe(root);
})();
</script>
With reduced motion: Words show fully inked on the paper with no spread, blur or fade.
Running cost: An animated mask and blur per word plus a static SVG noise filter; moderate cost, best on one headline.
A monospace header menu for an architecture practice on warm paper. Every label shuffles through capitals and digits and resettles left to right whenever a pointer enters it or keyboard focus lands on it, while a terracotta rule draws in underneath. The labels decode once as the header comes into view, and a slow demo touches one link at a time until someone arrives.
<section class="w3nvs">
<header class="w3nvs-bar">
<a href="#" class="w3nvs-brand">Ostrand Atelier</a>
<nav aria-label="Main">
<ul class="w3nvs-links">
<li><a href="#" aria-label="Projects"><span aria-hidden="true">Projects</span></a></li>
<li><a href="#" aria-label="Studio"><span aria-hidden="true">Studio</span></a></li>
<li><a href="#" aria-label="Journal"><span aria-hidden="true">Journal</span></a></li>
<li><a href="#" aria-label="Process"><span aria-hidden="true">Process</span></a></li>
<li><a href="#" aria-label="Contact"><span aria-hidden="true">Contact</span></a></li>
</ul>
</nav>
</header>
<div class="w3nvs-main">
<div class="w3nvs-hero">
<p class="w3nvs-kicker">Architecture and interiors</p>
<h2>Houses drawn around the light of one plot.</h2>
<p class="w3nvs-copy">A four-person practice working on homes, small studios and careful renovations, from the first site walk to the last coat of lime wash.</p>
<a href="#" class="w3nvs-cta">See recent houses</a>
</div>
<div class="w3nvs-index">
<h3>Recent work</h3>
<ul class="w3nvs-links w3nvs-list">
<li><a href="#" aria-label="Hollin House, 2025"><span aria-hidden="true">Hollin House, 2025</span></a></li>
<li><a href="#" aria-label="Brae Studio, 2024"><span aria-hidden="true">Brae Studio, 2024</span></a></li>
<li><a href="#" aria-label="Marl Lane Barn, 2024"><span aria-hidden="true">Marl Lane Barn, 2024</span></a></li>
</ul>
</div>
</div>
</section>
<style>
/* Change these: --w3nvs-bg (paper), --w3nvs-ink (text), --w3nvs-accent (underline and button). */
.w3nvs { --w3nvs-bg: #f3ede2; --w3nvs-ink: #1f1b16; --w3nvs-accent: #8f4528;
background: var(--w3nvs-bg); color: var(--w3nvs-ink); font-family: ui-sans-serif, system-ui, sans-serif; padding: 0 20px 88px; }
.w3nvs-bar { max-width: 72rem; margin: 0 auto; display: flex; flex-wrap: wrap; gap: 8px 24px; align-items: center; justify-content: space-between; padding: 20px 0; border-bottom: 1px solid #d8cdbb; }
.w3nvs-brand { color: inherit; text-decoration: none; font-weight: 600; font-size: 17px; letter-spacing: -.01em; min-height: 40px; display: inline-flex; align-items: center; }
.w3nvs-links { display: flex; flex-wrap: wrap; gap: 0 4px; margin: 0 -12px; padding: 0; list-style: none; }
.w3nvs-links a { position: relative; display: inline-flex; align-items: center; min-height: 44px; padding: 0 12px; color: inherit; text-decoration: none;
font: 500 13px/1 ui-monospace, "SF Mono", Menlo, Consolas, monospace; letter-spacing: .12em; text-transform: uppercase; white-space: pre; }
.w3nvs-links a::after { content: ""; position: absolute; left: 12px; right: 12px; bottom: 10px; height: 1px; background: var(--w3nvs-accent);
transform: scaleX(0); transform-origin: left; transition: transform .35s ease; }
.w3nvs-links a:hover::after, .w3nvs-links a:focus-visible::after, .w3nvs-links a[data-demo]::after { transform: scaleX(1); }
.w3nvs a:focus-visible { outline: 2px solid var(--w3nvs-accent); outline-offset: 2px; border-radius: 4px; }
.w3nvs-main { max-width: 72rem; margin: 0 auto; display: grid; gap: 56px; padding-top: 72px; }
.w3nvs-index h3 { margin: 0 0 8px; font: 500 12px ui-monospace, Menlo, monospace; letter-spacing: .2em; text-transform: uppercase; color: #6b5e4d; }
.w3nvs-list { display: block; margin: 0; border-top: 1px solid #d8cdbb; }
.w3nvs-list li { border-bottom: 1px solid #d8cdbb; }
.w3nvs-list a { display: flex; min-height: 56px; padding: 0; letter-spacing: .06em; }
.w3nvs-list a::after { left: 0; right: auto; width: 2.5em; bottom: 14px; }
@media (min-width: 900px) { .w3nvs-main { grid-template-columns: 1fr 300px; align-items: end; } }
.w3nvs-kicker { margin: 0; font: 500 12px ui-monospace, Menlo, monospace; letter-spacing: .2em; text-transform: uppercase; color: #6b5e4d; }
.w3nvs-hero h2 { margin: 18px 0 0; max-width: 40rem; font-family: Georgia, "Times New Roman", serif; font-weight: 400; font-size: clamp(34px, 5.4vw, 60px); line-height: 1.08; letter-spacing: -.02em; }
.w3nvs-copy { max-width: 34rem; margin: 22px 0 0; font-size: 17px; line-height: 1.6; color: #4e4438; }
.w3nvs-cta { display: inline-flex; align-items: center; min-height: 48px; margin-top: 32px; padding: 0 24px; border-radius: 999px; background: var(--w3nvs-ink); color: var(--w3nvs-bg); text-decoration: none; font-weight: 600; font-size: 15px; }
.w3nvs-cta:hover { background: var(--w3nvs-accent); }
@media (prefers-reduced-motion: reduce) { .w3nvs-links a::after { transition: none; } }
</style>
<script>
(function () {
var GLYPHS = "ABCDEFHKLMNPRSTVXZ0123456789/+#"; /* characters shown while scrambling */
var STEP = 38; /* ms between one letter settling and the next */
var LEAD = 160; /* ms of noise before the first letter settles */
var IDLE = 2600; /* ms between demo scrambles until a visitor arrives */
var root = document.querySelector(".w3nvs");
if (!root) return;
var links = [].slice.call(root.querySelectorAll(".w3nvs-links a"));
var reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
function scramble(a, delay) {
var out = a.firstChild, text = a.getAttribute("aria-label"), t0 = performance.now() + (delay || 0);
cancelAnimationFrame(a._f);
(function frame(now) {
var t = now - t0, s = "", done = true;
for (var i = 0; i < text.length; i++) {
if (t > LEAD + i * STEP) s += text[i];
else { done = false; s += t < 0 && delay ? text[i] : GLYPHS[Math.random() * GLYPHS.length | 0]; }
}
out.textContent = s;
a._f = done ? 0 : requestAnimationFrame(frame);
})(t0);
}
if (reduce) return;
var idle = 0, n = 0, live = false, seen = false;
function demo() {
links.forEach(function (l) { l.removeAttribute("data-demo"); });
var a = links[n++ % links.length];
a.setAttribute("data-demo", "");
scramble(a);
}
function startDemo() { idle = setTimeout(function step() { demo(); idle = setTimeout(step, IDLE); }, 1800); }
function stopDemo() { live = true; clearTimeout(idle); links.forEach(function (l) { l.removeAttribute("data-demo"); }); }
links.forEach(function (a) {
a.addEventListener("pointerenter", function () { stopDemo(); scramble(a); });
a.addEventListener("focus", function () { stopDemo(); scramble(a); });
});
var io = new IntersectionObserver(function (e) {
clearTimeout(idle);
if (!e[0].isIntersecting) return;
if (!seen) { seen = true; links.forEach(function (a, i) { scramble(a, i * 50); }); }
if (!live) startDemo();
});
io.observe(root);
})();
</script>
With reduced motion: The menu labels stay as plain words and only the thin underline appears under the hovered or focused link.
Running cost: Each scramble rewrites one short label for about half a second, then nothing runs until the next hover or focus.
A paragraph sits almost lost in a dark room until a warm lamp of light moves over it, following the pointer with a soft lag. With no pointer the light wanders on its own path. Touch screens and a Show full text button reveal everything at once, so nothing is locked behind hover.
<section class="pst-root" data-pst>
<div class="pst-wrap">
<p class="pst-label">Hollow Oak Cellars</p>
<div class="pst-stage">
<h2 class="pst-text pst-dim">The cellar tour starts after dark. Bring a coat, leave your phone, and taste what the stone keeps cool all year.</h2>
<p class="pst-text pst-lit" aria-hidden="true">The cellar tour starts after dark. Bring a coat, leave your phone, and taste what the stone keeps cool all year.</p>
<div class="pst-glow" aria-hidden="true"></div>
</div>
<div class="pst-actions">
<a href="#" class="pst-btn">Book a Friday tour</a>
<button type="button" class="pst-toggle" aria-pressed="false">Show full text</button>
</div>
</div>
</section>
<style>
/* Change these: --pst-bg (background), --pst-light (lit text), --pst-shadow (text outside the light), --pst-r (spotlight size). */
.pst-root { --pst-bg: #11140f; --pst-light: #f5e6c4; --pst-shadow: #343a2c; --pst-r: 170px;
background: radial-gradient(100% 80% at 50% 0%, #1b1f17 0%, var(--pst-bg) 70%); color: var(--pst-light); padding: 64px 20px; font-family: system-ui, sans-serif; }
@media (min-width: 640px) { .pst-root { padding: 80px 40px; } }
.pst-wrap { max-width: 56rem; margin: 0 auto; }
.pst-label { margin: 0; font-size: .875rem; font-weight: 600; letter-spacing: .2em; text-transform: uppercase; color: #d6a85c; }
.pst-stage { --pst-x: 30%; --pst-y: 50%; position: relative; display: grid; padding: 8px 0; margin-top: 24px; touch-action: pan-y; }
.pst-stage > * { grid-area: 1 / 1; }
.pst-text { font-family: Georgia, "Iowan Old Style", "Times New Roman", serif; font-weight: 400; font-size: clamp(1.7rem, 4.6vw, 3.4rem);
line-height: 1.22; letter-spacing: -.015em; margin: 0; }
.pst-dim { color: var(--pst-shadow); }
.pst-lit { color: var(--pst-light); text-shadow: 0 0 24px rgba(245,198,120,.25);
-webkit-mask-image: radial-gradient(var(--pst-r) circle at var(--pst-x) var(--pst-y), #000 35%, rgba(0,0,0,.35) 65%, transparent 100%);
mask-image: radial-gradient(var(--pst-r) circle at var(--pst-x) var(--pst-y), #000 35%, rgba(0,0,0,.35) 65%, transparent 100%); }
.pst-glow { pointer-events: none; background: radial-gradient(calc(var(--pst-r) * 1.6) circle at var(--pst-x) var(--pst-y), rgba(245,198,120,.09), transparent 70%); }
.pst-stage[data-open] .pst-lit { -webkit-mask-image: none; mask-image: none; }
.pst-stage[data-open] .pst-glow { display: none; }
.pst-actions { margin-top: 36px; display: flex; flex-wrap: wrap; gap: 12px; }
.pst-btn, .pst-toggle { min-height: 44px; display: inline-flex; align-items: center; border-radius: 999px; font: 600 .875rem/1 system-ui, sans-serif; cursor: pointer; }
.pst-btn { padding: 0 24px; background: #d6a85c; color: #14170f; text-decoration: none; }
.pst-toggle { padding: 0 20px; background: none; border: 1px solid #5b5440; color: var(--pst-light); font-weight: 500; }
.pst-btn:focus-visible, .pst-toggle:focus-visible { outline: 2px solid #d6a85c; outline-offset: 3px; }
@media (hover: none) { .pst-lit { -webkit-mask-image: none; mask-image: none; } .pst-glow { display: none; } }
@media (prefers-reduced-motion: reduce) { .pst-lit { -webkit-mask-image: none; mask-image: none; } .pst-glow { display: none; } }
</style>
<script>
(function () {
var root = document.querySelector("[data-pst]");
if (!root) return;
var stage = root.querySelector(".pst-stage");
var toggle = root.querySelector(".pst-toggle");
var EASE = 0.12; // how quickly the light catches up with the pointer
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var frame = 0, onScreen = true, open = reduce, target = null, idleUntil = 0, t0 = performance.now();
var cur = { x: stage.clientWidth * 0.3, y: stage.clientHeight * 0.5 };
function step(now) {
var w = stage.clientWidth, h = stage.clientHeight, t = (now - t0) / 1000;
var goal = target && now < idleUntil ? target
: { x: w * (0.5 + 0.38 * Math.sin(t * 0.55)), y: h * (0.5 + 0.32 * Math.sin(t * 0.9 + 1.2)) };
cur.x += (goal.x - cur.x) * EASE;
cur.y += (goal.y - cur.y) * EASE;
stage.style.setProperty("--pst-x", cur.x.toFixed(1) + "px");
stage.style.setProperty("--pst-y", cur.y.toFixed(1) + "px");
frame = requestAnimationFrame(step);
}
function start() { if (onScreen && !open && !frame) frame = requestAnimationFrame(step); }
function stop() { cancelAnimationFrame(frame); frame = 0; }
function setOpen(v) {
open = v || reduce;
stage.toggleAttribute("data-open", open);
toggle.setAttribute("aria-pressed", v ? "true" : "false");
toggle.textContent = v ? "Return to spotlight" : "Show full text";
if (open) stop(); else start();
}
stage.addEventListener("pointermove", function (e) {
var r = stage.getBoundingClientRect();
target = { x: e.clientX - r.left, y: e.clientY - r.top };
idleUntil = performance.now() + 2500;
});
toggle.addEventListener("click", function () { setOpen(toggle.getAttribute("aria-pressed") !== "true"); });
new IntersectionObserver(function (e) { onScreen = e[0].isIntersecting; if (onScreen) start(); else stop(); }).observe(root);
setOpen(false);
})();
</script>
With reduced motion: The paragraph is fully lit from the start and the wandering light never runs.
Running cost: One frame loop writing two custom properties to an animated mask; light, and stopped off screen or when opened.
A forest green hero for a flour mill where the second line of the headline cycles through three grains. Each change shuffles the amber monospace line in lower case and settles it from the left, then rests for several seconds so it reads as a still sentence most of the time. Three buttons jump to a grain and stop the rotation, and hovering the headline replays the current one.
<section class="w3rph">
<div class="w3rph-wrap">
<div class="w3rph-text">
<p class="w3rph-kicker">Fennick Mill</p>
<h2>
<span class="w3rph-sr">We stone-mill heritage wheat, whole spelt and dark rye.</span>
<span aria-hidden="true">We stone-mill</span>
<span class="w3rph-word" aria-hidden="true">heritage wheat.</span>
</h2>
<p class="w3rph-copy">Grain from three farms within a morning's drive, ground slowly between French burr stones and bagged the same week.</p>
<div class="w3rph-picks" role="group" aria-label="Show a flour">
<button type="button" aria-pressed="true">Wheat</button>
<button type="button" aria-pressed="false">Spelt</button>
<button type="button" aria-pressed="false">Rye</button>
</div>
</div>
<img class="w3rph-photo" src="https://images.pexels.com/photos/6294375/pexels-photo-6294375.jpeg?auto=compress&cs=tinysrgb&w=900" alt="A steel scoop resting in an open paper sack of fresh white flour" width="900" height="1125" loading="lazy">
</div>
</section>
<style>
/* Change these: --w3rph-bg (forest), --w3rph-ink (text), --w3rph-accent (the changing words). */
.w3rph { --w3rph-bg: #1c2a21; --w3rph-ink: #f1ead9; --w3rph-accent: #e6b860;
background: var(--w3rph-bg); color: var(--w3rph-ink); font-family: ui-sans-serif, system-ui, sans-serif; padding: 72px 20px; }
.w3rph-wrap { max-width: 68rem; margin: 0 auto; display: grid; gap: 40px; align-items: center; }
.w3rph-kicker { margin: 0; font-size: 12px; font-weight: 600; letter-spacing: .24em; text-transform: uppercase; color: #a9bba5; }
.w3rph h2 { margin: 18px 0 0; font: 400 clamp(36px, 5.6vw, 64px)/1.08 Georgia, "Times New Roman", serif; letter-spacing: -.02em; }
.w3rph h2 span { display: block; }
.w3rph-word { margin-top: 6px; font: 600 .82em/1.15 ui-monospace, "SF Mono", Menlo, Consolas, monospace; letter-spacing: -.03em; color: var(--w3rph-accent); white-space: pre; }
.w3rph-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.w3rph-copy { max-width: 32rem; margin: 24px 0 0; font-size: 17px; line-height: 1.6; color: #c9cdbd; }
.w3rph-picks { display: flex; gap: 8px; margin-top: 32px; }
.w3rph-picks button { min-height: 44px; padding: 0 18px; border: 1px solid #3d5244; border-radius: 999px; background: transparent; color: var(--w3rph-ink); font: 500 14px ui-sans-serif, system-ui, sans-serif; cursor: pointer; }
.w3rph-picks button:hover { border-color: #6d8670; }
.w3rph-picks button[aria-pressed="true"] { background: var(--w3rph-ink); border-color: var(--w3rph-ink); color: var(--w3rph-bg); }
.w3rph-picks button:focus-visible { outline: 2px solid var(--w3rph-accent); outline-offset: 3px; }
.w3rph-photo { display: block; width: 100%; max-width: 380px; aspect-ratio: 4 / 5; height: auto; object-fit: cover; border-radius: 18px; box-shadow: 0 30px 60px -30px rgba(0,0,0,.6); }
@media (min-width: 860px) { .w3rph-wrap { grid-template-columns: 1fr 380px; } }
</style>
<script>
(function () {
var PHRASES = ["heritage wheat.", "whole spelt.", "dark rye."]; /* keep them short; the longest sets the width */
var GLYPHS = "abcdefghkmnprstuvwxyz#/+=";
var STEP = 42, LEAD = 180, HOLD = 3600; /* ms per letter, noise before settling, rest on each phrase */
var root = document.querySelector(".w3rph");
if (!root) return;
var out = root.querySelector(".w3rph-word"), btns = root.querySelectorAll(".w3rph-picks button");
var W = Math.max.apply(null, PHRASES.map(function (p) { return p.length; }));
var cur = 0, f = 0, timer = 0, auto = true, on = false;
var reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
function pad(s) { while (s.length < W) s += " "; return s; }
function go(n) {
cur = n;
btns.forEach(function (b, i) { b.setAttribute("aria-pressed", i === n); });
var to = pad(PHRASES[n]), from = pad(out.textContent), t0 = performance.now();
cancelAnimationFrame(f); clearTimeout(timer);
if (reduce) { out.textContent = PHRASES[n]; return; }
(function frame(now) {
var t = now - t0, s = "", done = true;
for (var i = 0; i < W; i++) {
if (t > LEAD + i * STEP) s += to[i];
else { done = false; s += to[i] === " " && from[i] === " " ? " " : GLYPHS[Math.random() * GLYPHS.length | 0]; }
}
out.textContent = s;
f = done ? 0 : requestAnimationFrame(frame);
if (done && auto && on) timer = setTimeout(function () { go((cur + 1) % PHRASES.length); }, HOLD);
})(t0);
}
btns.forEach(function (b, i) {
b.addEventListener("click", function () { auto = false; go(i); });
});
out.textContent = pad(PHRASES[0]);
if (reduce) return;
root.querySelector("h2").addEventListener("pointerenter", function () { if (!f) go(cur); });
var io = new IntersectionObserver(function (e) {
on = e[0].isIntersecting;
clearTimeout(timer);
if (on && auto) go(cur);
});
io.observe(root);
})();
</script>
With reduced motion: The first phrase stays put, and the three buttons swap the phrase instantly with no letter noise.
Running cost: One short frame loop rewrites a single line for under a second per change, then a timer waits; both stop off screen.
A thin sea glass light travels down a graphite plate and leaves the headline behind it, as if a document scanner has just read it. Before the pass you see only a faint outline of the letters, so the frame is never empty. It suits archiving, document, lab and data products.
<section class="slb-root" data-slb>
<div class="slb-wrap">
<p class="slb-label">Northlight Archiving</p>
<h1 class="slb-h">
<span class="slb-sr">Every page, read once and kept for good.</span>
<span class="slb-plate" aria-hidden="true">
<span class="slb-ghost">Every page, read once and kept for good.</span>
<span class="slb-lit">Every page, read once and kept for good.</span>
<span class="slb-track"><span class="slb-bar"></span></span>
</span>
</h1>
<p class="slb-copy">Deeds, letters and ledgers digitised on site, indexed by hand, and returned to you the same week.</p>
<a href="#" class="slb-btn">Arrange a collection</a>
</div>
</section>
<style>
/* Change these: --slb-bg (graphite background), --slb-text (revealed text), --slb-light (scan bar colour), --slb-cycle (one scan, rest and fade). */
.slb-root { --slb-bg: #16191c; --slb-text: #e7ece9; --slb-light: #9fe3d0; --slb-cycle: 7s;
background: radial-gradient(90% 80% at 50% 0%, #23282c 0%, var(--slb-bg) 60%, #111315 100%); color: var(--slb-text);
padding: 80px 20px; font-family: system-ui, sans-serif; }
@media (min-width: 640px) { .slb-root { padding: 96px 40px; } }
.slb-wrap { max-width: 56rem; margin: 0 auto; }
.slb-label { margin: 0; font: .75rem ui-monospace, Menlo, monospace; letter-spacing: .25em; text-transform: uppercase; color: var(--slb-light); }
.slb-h { font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; font-weight: 650;
font-size: clamp(2rem, 6vw, 4.4rem); line-height: 1.04; letter-spacing: -.035em; margin: 24px 0 0; }
.slb-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.slb-plate { position: relative; display: grid; padding: 28px 22px; border-radius: 14px;
background: linear-gradient(180deg, rgba(255,255,255,.035), rgba(255,255,255,.01)); box-shadow: inset 0 0 0 1px rgba(255,255,255,.06); }
.slb-plate > * { grid-area: 1 / 1; }
.slb-ghost { color: transparent; -webkit-text-stroke: 1px rgba(159,227,208,.16); }
.slb-lit { animation: slb-clip var(--slb-cycle) cubic-bezier(.45,.05,.35,1) infinite both; }
@keyframes slb-clip {
0% { clip-path: inset(0 0 100% 0); opacity: 1 } 22% { clip-path: inset(0 0 0 0); opacity: 1 }
88% { clip-path: inset(0 0 0 0); opacity: 1 } 97%, 100% { clip-path: inset(0 0 0 0); opacity: 0 } }
.slb-track { position: relative; pointer-events: none; animation: slb-move var(--slb-cycle) cubic-bezier(.45,.05,.35,1) infinite both; }
.slb-bar { position: absolute; left: -22px; right: -22px; top: -1px; height: 2px; background: #c9f5e8;
box-shadow: 0 0 6px 1px rgba(159,227,208,.9), 0 0 28px 6px rgba(159,227,208,.35); }
.slb-bar::before { content: ""; position: absolute; left: 0; right: 0; bottom: 2px; height: 70px;
background: linear-gradient(0deg, rgba(159,227,208,.16), transparent); }
@keyframes slb-move {
0% { transform: translateY(0); opacity: 0 } 3% { opacity: 1 } 22% { transform: translateY(100%); opacity: 1 }
27%, 100% { transform: translateY(100%); opacity: 0 } }
.slb-copy { margin: 28px 0 0; max-width: 36rem; font-size: 1.0625rem; line-height: 1.65; color: #b4bdb9; }
.slb-btn { margin-top: 32px; min-height: 44px; display: inline-flex; align-items: center; padding: 0 24px; border-radius: 6px;
background: var(--slb-light); color: #0f1513; font-size: .875rem; font-weight: 600; text-decoration: none; }
.slb-btn:focus-visible { outline: 2px solid var(--slb-light); outline-offset: 3px; }
.slb-root[data-paused] .slb-lit, .slb-root[data-paused] .slb-track { animation-play-state: paused; }
@media (prefers-reduced-motion: reduce) { .slb-lit { animation: none; } .slb-track { display: none; } }
</style>
<script>
(function () {
// Pause the scan while the section is off screen.
var root = document.querySelector("[data-slb]");
if (!root) return;
new IntersectionObserver(function (e) {
root.toggleAttribute("data-paused", !e[0].isIntersecting);
}).observe(root);
})();
</script>
With reduced motion: The headline shows fully lit on its plate and the light bar is hidden.
Running cost: One clip-path animation and one transformed bar; light enough for any page, paused off screen.
A long editorial paragraph inside a scrolling panel where each word starts faded and comes up to full ink as it passes the middle of the panel. Browsers with scroll-driven animations do it in pure CSS; others get an IntersectionObserver that lights words instead. A slow demo scroll runs until the reader touches it.
<section class="swb-root" data-swb>
<div class="swb-grid">
<div>
<p class="swb-label">Saltmarsh Press</p>
<h2 class="swb-title">A note from the bindery</h2>
<p class="swb-hint">Scroll the passage. Each word brightens as it reaches your reading line.</p>
<a href="#" class="swb-btn">See the autumn list</a>
</div>
<div class="swb-box" tabindex="0" role="region" aria-label="A note from the bindery, scrollable">
<p class="swb-text">We print forty copies of every book, sewn by hand at a long table under the north window. The paper comes from a mill two valleys over, still damp when it arrives. Nobody here is in a hurry. A book takes the time it takes, and then it goes out into the world to be read slowly, in good light, by someone who chose it on purpose.</p>
</div>
</div>
</section>
<style>
/* Change these: --swb-paper (background), --swb-ink (text), --swb-dim (opacity of unread words), --swb-accent (label and focus). */
.swb-root { --swb-paper: #f3ede1; --swb-ink: #2a2118; --swb-dim: .2; --swb-accent: #8a4f26;
background: var(--swb-paper); color: var(--swb-ink); padding: 64px 20px; font-family: system-ui, sans-serif; }
.swb-grid { max-width: 64rem; margin: 0 auto; display: grid; gap: 40px; }
@media (min-width: 768px) { .swb-grid { grid-template-columns: 220px 1fr; } .swb-root { padding: 80px 40px; } }
.swb-label { margin: 0; font-size: .75rem; font-weight: 600; letter-spacing: .22em; text-transform: uppercase; color: var(--swb-accent); }
.swb-title { margin: 12px 0 0; font-size: 1.5rem; line-height: 1.35; }
.swb-hint { margin: 12px 0 0; font-size: .875rem; line-height: 1.6; color: #5b4d3d; }
.swb-btn { margin-top: 24px; min-height: 44px; display: inline-flex; align-items: center; padding: 0 20px; border-radius: 6px;
background: var(--swb-ink); color: var(--swb-paper); font-size: .875rem; font-weight: 600; text-decoration: none; }
.swb-btn:focus-visible, .swb-box:focus-visible { outline: 2px solid var(--swb-accent); outline-offset: 4px; }
.swb-box { height: 360px; overflow-y: auto; scrollbar-width: thin; scrollbar-color: #c8bba3 transparent;
-webkit-mask-image: linear-gradient(transparent, #000 14%, #000 80%, transparent);
mask-image: linear-gradient(transparent, #000 14%, #000 80%, transparent); }
.swb-text { font-family: Georgia, "Iowan Old Style", "Times New Roman", serif; font-size: clamp(1.45rem, 3.4vw, 2.25rem);
line-height: 1.45; letter-spacing: -.005em; padding: 120px 0 260px; margin: 0; }
.swb-w { opacity: var(--swb-dim); }
@supports (animation-timeline: view()) {
.swb-w { animation: swb-lit linear both; animation-timeline: view(); animation-range: cover 18% cover 48%; }
}
@keyframes swb-lit { from { opacity: var(--swb-dim) } to { opacity: 1 } }
.swb-box[data-fallback] .swb-w { animation: none; transition: opacity .6s ease; }
.swb-box[data-fallback] .swb-w.swb-lit { opacity: 1; }
@media (prefers-reduced-motion: reduce) { .swb-w, .swb-box[data-fallback] .swb-w { animation: none; opacity: 1; transition: none; } }
</style>
<script>
(function () {
var root = document.querySelector("[data-swb]");
if (!root) return;
var box = root.querySelector(".swb-box");
var para = root.querySelector(".swb-text");
var AUTO_PX_PER_SEC = 22; // speed of the demo scroll before anyone touches it
// Wrap each word in a span, keeping the paragraph as one readable sentence.
var words = para.textContent.trim().split(/\s+/);
para.textContent = "";
words.forEach(function (w) {
var s = document.createElement("span");
s.className = "swb-w";
s.textContent = w;
para.appendChild(s);
para.appendChild(document.createTextNode(" "));
});
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) return;
// Fallback when scroll-driven animations are missing.
if (!(window.CSS && CSS.supports("animation-timeline: view()"))) {
box.setAttribute("data-fallback", "");
var wio = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
var above = e.rootBounds && e.boundingClientRect.top < e.rootBounds.top;
e.target.classList.toggle("swb-lit", e.isIntersecting || above);
});
}, { root: box, rootMargin: "0px 0px -45% 0px" });
para.querySelectorAll(".swb-w").forEach(function (w) { wio.observe(w); });
}
// Gentle demo scroll; a wheel, touch, click or key hands control back for good.
var frame = 0, last = 0, pos = 0, onScreen = true, stopped = false;
function step(now) {
var dt = last ? Math.min(now - last, 50) : 0;
last = now;
pos += AUTO_PX_PER_SEC * dt / 1000;
if (pos >= box.scrollHeight - box.clientHeight) pos = 0;
box.scrollTop = pos;
frame = requestAnimationFrame(step);
}
function start() { if (!stopped && onScreen && !frame) { last = 0; frame = requestAnimationFrame(step); } }
function stop() { cancelAnimationFrame(frame); frame = 0; }
var io = new IntersectionObserver(function (e) { onScreen = e[0].isIntersecting; if (onScreen) start(); else stop(); });
io.observe(root);
["wheel", "touchstart", "pointerdown", "keydown"].forEach(function (ev) {
box.addEventListener(ev, function () { stopped = true; stop(); }, { passive: true });
});
start();
})();
</script>
With reduced motion: Every word shows at full strength and the demo scroll never starts.
Running cost: Opacity on a few dozen spans driven by the scroll timeline; one light frame loop for the demo scroll, paused off screen.
A three line serif headline where a block of clay colour sweeps across each line, and the words are there when it leaves. The lines follow each other a beat apart, rest for several seconds, then the curtain closes back over them before the next pass. Suits craft, studio and editorial landing pages.
<section class="cmw-root" data-cmw>
<div class="cmw-wrap">
<p class="cmw-label">Kiln and Clay Studio</p>
<h1 class="cmw-h">
<span class="cmw-line" style="--i:0"><span class="cmw-text">Thrown by hand,</span></span>
<span class="cmw-line" style="--i:1"><span class="cmw-text">fired slowly,</span></span>
<span class="cmw-line" style="--i:2"><span class="cmw-text">kept for decades.</span></span>
</h1>
<div class="cmw-foot">
<p class="cmw-copy">Stoneware cups, bowls and serving plates from a two-person workshop. New pieces leave the kiln every second Friday.</p>
<a href="#" class="cmw-btn">Visit the shop</a>
</div>
</div>
</section>
<style>
/* Change these: --cmw-sand (background), --cmw-clay (curtain colour), --cmw-ink (text), --cmw-cycle (length of one wipe in and out), --cmw-stagger (delay between lines). */
.cmw-root { --cmw-sand: #efe3cf; --cmw-clay: #b4532a; --cmw-ink: #2b1d14; --cmw-cycle: 8s; --cmw-stagger: 140ms;
background: var(--cmw-sand); color: var(--cmw-ink); padding: 80px 20px; font-family: system-ui, sans-serif;
background-image: radial-gradient(90% 70% at 85% 10%, rgba(255,255,255,.55), transparent 60%), radial-gradient(70% 60% at 0% 100%, rgba(180,83,42,.10), transparent 70%); }
@media (min-width: 640px) { .cmw-root { padding: 96px 40px; } }
.cmw-wrap { max-width: 64rem; margin: 0 auto; }
.cmw-label { margin: 0; font-size: .875rem; font-weight: 600; letter-spacing: .2em; text-transform: uppercase; color: #8a3b1c; }
.cmw-h { font-family: Georgia, "Iowan Old Style", "Times New Roman", serif; font-weight: 400;
font-size: clamp(2.3rem, 7.5vw, 5.4rem); line-height: 1.02; letter-spacing: -.025em; margin: 24px 0 0; }
.cmw-line { position: relative; display: block; width: fit-content; overflow: hidden; padding: 0 .06em .06em 0; }
.cmw-line:nth-child(2) { font-style: italic; color: #8a3b1c; }
.cmw-text { display: block; animation: cmw-text var(--cmw-cycle) cubic-bezier(.7,0,.2,1) infinite both; animation-delay: calc(var(--i) * var(--cmw-stagger)); }
.cmw-line::after { content: ""; position: absolute; inset: 0; background: var(--cmw-clay); transform: translateX(-101%);
animation: cmw-curtain var(--cmw-cycle) cubic-bezier(.7,0,.2,1) infinite both; animation-delay: calc(var(--i) * var(--cmw-stagger)); }
@keyframes cmw-curtain {
0% { transform: translateX(-101%) } 7% { transform: translateX(0) } 15% { transform: translateX(101%) }
84% { transform: translateX(101%) } 90% { transform: translateX(0) } 97%, 100% { transform: translateX(-101%) } }
@keyframes cmw-text { 0%, 6.9% { opacity: 0 } 7%, 89.9% { opacity: 1 } 90%, 100% { opacity: 0 } }
.cmw-root[data-paused] .cmw-text, .cmw-root[data-paused] .cmw-line::after { animation-play-state: paused; }
.cmw-foot { margin-top: 40px; display: flex; flex-direction: column; gap: 24px; }
@media (min-width: 640px) { .cmw-foot { flex-direction: row; align-items: flex-end; justify-content: space-between; } }
.cmw-copy { margin: 0; max-width: 28rem; font-size: 1.0625rem; line-height: 1.65; color: #5a4433; }
.cmw-btn { min-height: 44px; width: fit-content; display: inline-flex; align-items: center; padding: 0 24px;
border: 2px solid var(--cmw-ink); color: var(--cmw-ink); font-size: .875rem; font-weight: 600; letter-spacing: .03em; text-decoration: none; }
.cmw-btn:hover { background: var(--cmw-ink); color: var(--cmw-sand); }
.cmw-btn:focus-visible { outline: 2px solid var(--cmw-clay); outline-offset: 3px; }
@media (prefers-reduced-motion: reduce) {
.cmw-text { animation: none; opacity: 1; }
.cmw-line::after { animation: none; transform: translateX(101%); }
}
</style>
<script>
(function () {
// Pause the loop while the section is off screen.
var root = document.querySelector("[data-cmw]");
if (!root) return;
new IntersectionObserver(function (e) {
root.toggleAttribute("data-paused", !e[0].isIntersecting);
}).observe(root);
})();
</script>
With reduced motion: The headline sits fully visible and the curtain block stays parked out of view.
Running cost: Two transform and opacity animations per line on the compositor; the script only pauses them off screen.
Each letter of a bold serif headline falls into place with a short squash on landing and a tiny rebound, one after another from the left, like type being set by hand. Words never break mid-line because letters are grouped per word. A good fit for bakeries, food brands and friendly shop fronts.
<section class="wld-root" data-wld>
<div class="wld-wrap">
<p class="wld-label">Fernhollow Bakery</p>
<h1 class="wld-h"><span class="wld-sr">Bread worth waking for.</span><span class="wld-letters" aria-hidden="true"></span></h1>
<p class="wld-copy">Sourdough, rye and seeded loaves from a wood oven lit at four. Collect from the side door until we sell out.</p>
<a href="#" class="wld-btn">Reserve tomorrow's loaf</a>
</div>
</section>
<style>
/* Change these: --wld-cream (background), --wld-forest (letters), --wld-cycle (loop length), --wld-stagger (gap between letters). The squash lives in the 6% and 8.5% keyframes. */
.wld-root { --wld-cream: #f2efe6; --wld-forest: #1f3b2d; --wld-cycle: 8s; --wld-stagger: 42ms;
background: var(--wld-cream); color: var(--wld-forest); padding: 80px 20px; font-family: system-ui, sans-serif;
background-image: linear-gradient(180deg, rgba(255,255,255,.5), transparent 40%), radial-gradient(80% 60% at 100% 100%, rgba(196,148,82,.16), transparent 70%); }
@media (min-width: 640px) { .wld-root { padding: 96px 40px; } }
.wld-wrap { max-width: 64rem; margin: 0 auto; }
.wld-label { margin: 0; font-size: .875rem; font-weight: 600; letter-spacing: .2em; text-transform: uppercase; color: #8a5a24; }
.wld-h { font-family: Georgia, "Iowan Old Style", "Times New Roman", serif; font-weight: 700;
font-size: clamp(2.5rem, 8.4vw, 6rem); line-height: 1.02; letter-spacing: -.03em; margin: 20px 0 0; }
.wld-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.wld-word { display: inline-block; white-space: nowrap; }
.wld-ch { display: inline-block; transform-origin: 50% 100%;
animation: wld-drop var(--wld-cycle) linear infinite both; animation-delay: calc(var(--i) * var(--wld-stagger)); }
@keyframes wld-drop {
0% { transform: translateY(-0.55em); opacity: 0; animation-timing-function: cubic-bezier(.5,0,.9,.4) }
6% { transform: translateY(0) scale(1.06, .9); opacity: 1; animation-timing-function: cubic-bezier(.2,.7,.4,1) }
8.5% { transform: translateY(-0.07em) scale(.98, 1.03) }
11%, 88% { transform: none; opacity: 1 }
94%, 100% { transform: translateY(.18em); opacity: 0 } }
.wld-copy { margin: 28px 0 0; max-width: 32rem; font-size: 1.0625rem; line-height: 1.65; color: #3c4f44; }
.wld-btn { margin-top: 32px; min-height: 44px; display: inline-flex; align-items: center; padding: 0 24px; border-radius: 8px;
background: var(--wld-forest); color: var(--wld-cream); font-size: .875rem; font-weight: 600; text-decoration: none; }
.wld-btn:focus-visible { outline: 2px solid var(--wld-forest); outline-offset: 3px; }
.wld-root[data-paused] .wld-ch { animation-play-state: paused; }
@media (prefers-reduced-motion: reduce) { .wld-ch { animation: none; } }
</style>
<script>
(function () {
var root = document.querySelector("[data-wld]");
if (!root) return;
// One span per letter, grouped by word so lines only break between words.
var holder = root.querySelector(".wld-letters");
var n = 0;
root.querySelector(".wld-sr").textContent.split(" ").forEach(function (word, w) {
if (w > 0) holder.appendChild(document.createTextNode(" "));
var group = document.createElement("span");
group.className = "wld-word";
for (var i = 0; i < word.length; i++) {
var s = document.createElement("span");
s.className = "wld-ch";
s.style.setProperty("--i", n++);
s.textContent = word[i];
group.appendChild(s);
}
holder.appendChild(group);
});
// Pause the loop while the section is off screen.
new IntersectionObserver(function (e) {
root.toggleAttribute("data-paused", !e[0].isIntersecting);
}).observe(root);
})();
</script>
With reduced motion: Letters are shown resting in place with no fall, squash or fade.
Running cost: Transform and opacity on about twenty letter spans; compositor only, paused off screen.
Mask and clip-path reveals repaint only the text being uncovered and finish quickly. Scroll-driven CSS animations run off the main thread in browsers that support them, so they stay smooth during busy scrolling; the fallback updates a single custom property per scroll frame.
The scramble changes text content on a timer and stops the moment every letter has settled. Word-by-word reveals split by word rather than letter, which keeps the number of animated elements small on long paragraphs.
In supporting browsers, attach the animation to scroll with animation-timeline: view() and an animation-range. Elsewhere, an IntersectionObserver or a scroll listener can set a progress value that drives the same styles.
It replaces each letter with random characters from a set for a short time, then settles them one by one into the real letters, usually from left to right. Keep the real text in a hidden readable element so screen readers ignore the noise.
Clip-path gives hard edges and is very cheap. Mask-image with a gradient gives soft, feathered edges at a little more cost. Both animate well when you change a single value.
Not when the text is in the HTML and only visually masked for a moment. Search engines read the markup; the animation is a visual layer on top of it.
More collections: CSS Text Animations, Glitch Text Effects in CSS, Typing Animations and Typewriter Effects. All text effects.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.