Updated 2026-10-14. 6 effects.
Website text animation brings a headline to life with motion, from simple CSS entrances to letters drawn by a pen or built from particles. These are the showpieces: each one runs on canvas, SVG or CSS, keeps the real words in the page for screen readers and search engines, and settles into readable, still text for reduced motion.
Save a showpiece for one moment on a site, usually the opening headline or a section that names the brand. Pair it with plain, fast text everywhere else; two expressive effects on one screen cancel each other out.
If you want a quieter effect, the CSS text animation, typing, reveal, counter and glitch collections linked below hold lighter options. Everything there and here comes as plain HTML, CSS and JavaScript and as a React component.
| Effect | Built with | Size (gzipped) | Running cost |
|---|---|---|---|
| Handwritten Word Drawn by Pen Stroke | CSS only | 1.7 KB | Two SVG paths animate stroke-dashoffset and opacity, which repaints only the small drawing; no script beyond an off-screen pause. |
| Kinetic Type Line Stretching With Scroll | CSS only | 1.8 KB | One transform per letter each frame from a small loop that reads scroll position once and stops off screen. |
| Letters Cut From Light With Moving Rays | Canvas 2D | 2.3 KB | About twenty-six scaled copies of a prepared text image blend into a third-size buffer each frame, then one blurred upscale; paused off screen. |
| Liquid Fill Word Rising to a Level | CSS only | 1.8 KB | Two wave paths and four bubbles move by transform inside one SVG clip, with no script beyond an off-screen pause. |
| Particle Headline Assembling Into Letters | Canvas 2D | 2.7 KB | Up to 4,200 small squares redrawn per frame on a 2D canvas capped at 2x pixel density, paused when off screen. |
| Text on a Circular Path Rotating Badge | CSS only | 1.9 KB | Two SVG groups rotate by transform on slow linear loops, with no script beyond an off-screen pause. |
A joined-up script "hello" in navy ink writes itself across faintly ruled cream paper in one unbroken stroke, loops and all, then an underline swash flicks in beneath it. The word holds for several seconds, fades like drying ink and is written again. The letters are a single hand-drawn SVG path, so the pen moves the way a real hand would rather than revealing a font.
<section class="w10pen" data-paused="false">
<div class="w10pen-lines" aria-hidden="true"></div>
<div class="w10pen-inner">
<p class="w10pen-eyebrow">Tallow & Quill stationers</p>
<h1 class="w10pen-title">
<span class="w10pen-sr">hello</span>
<svg class="w10pen-svg" aria-hidden="true" viewBox="0 0 430 205">
<path class="w10pen-word" pathLength="1" d="M20 160 C50 140 95 80 100 50 C104 28 86 22 80 40 C72 70 70 130 68 170 C76 135 96 110 112 112 C128 114 128 140 126 160 C125 172 140 172 152 160 C172 146 200 132 196 118 C192 104 168 108 164 128 C160 148 170 170 192 168 C214 166 232 120 244 70 C252 38 238 22 228 42 C218 64 214 130 222 160 C228 172 244 170 256 158 C272 140 290 110 300 66 C308 36 294 22 284 42 C274 66 270 130 278 160 C284 172 300 170 312 158 C318 140 334 116 350 116 C372 116 378 150 362 166 C348 178 326 166 332 142 C336 126 356 118 372 122 C386 126 396 118 410 112" />
<path class="w10pen-swash" pathLength="1" d="M70 190 C160 178 290 182 400 172" />
</svg>
</h1>
<p class="w10pen-copy">Letterpress cards, cotton paper and bottled fountain pen ink, wrapped by hand and posted every weekday.</p>
<a class="w10pen-btn" href="#">Browse new cards</a>
</div>
</section>
<style>
/* Change these: ink colour, the length of one write-hold-fade loop, and the stroke weight of the word. */
.w10pen { --w10pen-ink: #1f2a44; --w10pen-cycle: 8s; --w10pen-weight: 7; }
.w10pen { position: relative; overflow: hidden; background: #f7f2e8; padding: 80px 20px; font-family: ui-sans-serif, system-ui, sans-serif; text-align: center; }
.w10pen-lines { position: absolute; inset: 0; pointer-events: none; background: repeating-linear-gradient(to bottom, transparent 0 39px, rgba(31,42,68,0.07) 39px 40px); }
.w10pen-inner { position: relative; max-width: 896px; margin: 0 auto; }
.w10pen-eyebrow { margin: 0; font-size: 12px; font-weight: 600; letter-spacing: 0.3em; text-transform: uppercase; color: #6b5b45; }
.w10pen-title { margin: 16px 0 0; }
.w10pen-svg { display: block; width: 100%; max-width: 560px; height: auto; margin: 0 auto; }
.w10pen-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.w10pen-copy { max-width: 32rem; margin: 24px auto 0; font-size: 18px; line-height: 1.6; color: #4a4033; }
.w10pen-btn { display: inline-flex; align-items: center; height: 48px; margin-top: 32px; padding: 0 28px; border-radius: 999px; background: var(--w10pen-ink); color: #f7f2e8; font-size: 14px; font-weight: 600; text-decoration: none; }
.w10pen-btn:hover { background: #2f3e63; }
.w10pen-btn:focus-visible { outline: 2px solid var(--w10pen-ink); outline-offset: 3px; }
.w10pen-word, .w10pen-swash { fill: none; stroke: var(--w10pen-ink); stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 1; }
.w10pen-word { stroke-width: var(--w10pen-weight); stroke-dashoffset: 0; animation: w10pen-write var(--w10pen-cycle) cubic-bezier(0.45, 0.05, 0.4, 1) infinite; }
.w10pen-swash { stroke-width: 4; stroke-dashoffset: 0; animation: w10pen-swash var(--w10pen-cycle) ease-out infinite; }
@keyframes w10pen-write { 0% { stroke-dashoffset: 1; opacity: 1; } 30% { stroke-dashoffset: 0; } 86% { stroke-dashoffset: 0; opacity: 1; } 96% { stroke-dashoffset: 0; opacity: 0; } 100% { stroke-dashoffset: 1; opacity: 0; } }
@keyframes w10pen-swash { 0%, 29% { stroke-dashoffset: 1; opacity: 1; } 37% { stroke-dashoffset: 0; } 86% { stroke-dashoffset: 0; opacity: 1; } 96% { stroke-dashoffset: 0; opacity: 0; } 100% { stroke-dashoffset: 1; opacity: 0; } }
.w10pen[data-paused="true"] .w10pen-word, .w10pen[data-paused="true"] .w10pen-swash { animation-play-state: paused; }
@media (prefers-reduced-motion: reduce) { .w10pen-word, .w10pen-swash { animation: none; stroke-dashoffset: 0; } }
</style>
<script>
(function () {
var root = document.querySelector(".w10pen");
if (!root || !("IntersectionObserver" in window)) return;
new IntersectionObserver(function (entries) {
root.setAttribute("data-paused", entries[0].isIntersecting ? "false" : "true");
}).observe(root);
})();
</script>
With reduced motion: The script word and its underline are shown fully written in still ink.
Running cost: Two SVG paths animate stroke-dashoffset and opacity, which repaints only the small drawing; no script beyond an off-screen pause.
One heavy forest-green word sits on a ruled line across a cream page, and a smooth swell travels through its letters, pulling each one taller as it passes. Scrolling moves the swell along the word and briefly makes it stronger; with no scroll it wanders on its own. Letters grow upward from the baseline into reserved space, so nothing below the headline ever moves.
<section class="w10kin">
<div class="w10kin-inner">
<div class="w10kin-top"><span>Long Room Pilates</span><span class="w10kin-meta">Reformer · Mat · Barre</span></div>
<h1 class="w10kin-title"><span class="w10kin-sr">Lengthen</span><span class="w10kin-word" aria-hidden="true" data-word="LENGTHEN"></span></h1>
<div class="w10kin-row">
<p class="w10kin-copy">Slow, small-group classes that undo a week at the desk. Eight people per room, never more.</p>
<a class="w10kin-btn" href="#">Book a first class</a>
</div>
</div>
</section>
<style>
/* Change these: background, ink colour and the space kept above the letters for the stretch (padding-top on the title). */
.w10kin { --w10kin-bg: #f1ece0; --w10kin-ink: #1f3d2b; }
.w10kin { overflow: hidden; background: var(--w10kin-bg); padding: 80px 20px; font-family: ui-sans-serif, system-ui, sans-serif; color: var(--w10kin-ink); }
.w10kin-inner { max-width: 1024px; margin: 0 auto; }
.w10kin-top { display: flex; justify-content: space-between; gap: 16px; font-size: 12px; font-weight: 600; letter-spacing: 0.28em; text-transform: uppercase; }
.w10kin-title { margin: 40px 0 0; padding-top: 0.6em; border-bottom: 2px solid var(--w10kin-ink); text-align: center; font-size: clamp(52px, 13.2vw, 172px); font-weight: 900; line-height: 0.8; letter-spacing: -0.03em; }
.w10kin-word { display: inline-flex; align-items: flex-end; }
.w10kin-l { display: inline-block; transform-origin: 50% 100%; will-change: transform; }
.w10kin-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.w10kin-row { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 24px; margin-top: 32px; }
.w10kin-copy { max-width: 28rem; margin: 0; font-size: 18px; line-height: 1.6; color: #3a4a3f; }
.w10kin-btn { display: inline-flex; align-items: center; height: 48px; padding: 0 28px; border-radius: 999px; background: var(--w10kin-ink); color: var(--w10kin-bg); font-size: 14px; font-weight: 600; text-decoration: none; }
.w10kin-btn:hover { background: #2c5a40; }
.w10kin-btn:focus-visible { outline: 2px solid var(--w10kin-ink); outline-offset: 3px; }
@media (max-width: 640px) { .w10kin-meta { display: none; } }
@media (prefers-reduced-motion: reduce) { .w10kin-l { will-change: auto; } }
</style>
<script>
(function () {
/* Change these: peak stretch, extra stretch while scrolling, idle wander speed. */
var MAX_STRETCH = 0.55, SCROLL_BOOST = 0.4, IDLE_SPEED = 0.0007;
var root = document.querySelector(".w10kin");
if (!root) return;
var holder = root.querySelector(".w10kin-word");
var word = holder.getAttribute("data-word") || "LENGTHEN";
var spans = [];
for (var i = 0; i < word.length; i++) {
var s = document.createElement("span");
s.className = "w10kin-l";
s.textContent = word[i];
holder.appendChild(s);
spans.push(s);
}
var n = spans.length;
function apply(focus, amp) {
for (var i = 0; i < n; i++) {
var k = 1 + amp * Math.exp(-((i - focus) * (i - focus)) / 2.4);
spans[i].style.transform = "scaleY(" + k.toFixed(3) + ")";
}
}
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { apply((n - 1) / 2, MAX_STRETCH * 0.6); return; }
var frame = 0, visible = true, lastP = -1, boost = 0, amp = MAX_STRETCH;
function progress() {
var r = root.getBoundingClientRect(), vh = window.innerHeight || 1;
return Math.min(1, Math.max(0, (vh - r.top) / (vh + r.height)));
}
function tick(now) {
var p = progress();
if (lastP >= 0) boost = Math.min(SCROLL_BOOST, boost + Math.abs(p - lastP) * 6);
lastP = p; boost *= 0.93;
amp += (MAX_STRETCH + boost - amp) * 0.12;
apply((n - 1) / 2 + Math.sin(now * IDLE_SPEED - 1.2) * (n / 2) + (p - 0.5) * n, amp);
frame = requestAnimationFrame(tick);
}
function play() { if (!frame && visible) frame = requestAnimationFrame(tick); }
function stop() { cancelAnimationFrame(frame); frame = 0; }
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
if (visible) play(); else stop();
}).observe(root);
play();
})();
</script>
With reduced motion: The word rests with a soft, fixed swell over its middle letters and does not react to scrolling.
Running cost: One transform per letter each frame from a small loop that reads scroll position once and stops off screen.
Wide capitals glow warm white against a midnight blue wall, as if cut through it with a lamp behind, and soft shafts of light pour out of every letter. The hidden light source wanders slowly, so the beams swing and lengthen like dusk light through a stained window. It fits venues, galleries, churches and evening events where atmosphere matters more than speed.
<section class="w10lcut">
<div class="w10lcut-bg" aria-hidden="true"></div>
<div class="w10lcut-inner">
<p class="w10lcut-eyebrow">St. Aldric's chapel · evening series</p>
<h1 class="w10lcut-sr">Vesper</h1>
<div class="w10lcut-box" data-word="VESPER"><canvas class="w10lcut-canvas" aria-hidden="true"></canvas></div>
<p class="w10lcut-copy">Candlelit chamber music under the old timber roof, every Thursday at eight from October to March.</p>
<a class="w10lcut-btn" href="#">See the season</a>
</div>
</section>
<style>
.w10lcut { position: relative; overflow: hidden; background: #0d1016; padding: 80px 20px; font-family: ui-sans-serif, system-ui, sans-serif; text-align: center; }
.w10lcut-bg { position: absolute; inset: 0; pointer-events: none; background: radial-gradient(70% 60% at 50% 35%, #1b2130 0%, #0d1016 70%); }
.w10lcut-inner { position: relative; max-width: 1024px; margin: 0 auto; }
.w10lcut-eyebrow { margin: 0; font-size: 12px; font-weight: 600; letter-spacing: 0.34em; text-transform: uppercase; color: #b8b2a6; }
.w10lcut-sr { position: absolute; width: 1px; height: 1px; margin: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.w10lcut-box { position: relative; height: 300px; margin: 8px auto 0; }
.w10lcut-canvas { position: absolute; inset: 0; width: 100%; height: 100%; -webkit-mask-image: linear-gradient(to right, transparent, #000 7%, #000 93%, transparent), linear-gradient(to bottom, transparent, #000 16%, #000 84%, transparent); -webkit-mask-composite: source-in; mask-image: linear-gradient(to right, transparent, #000 7%, #000 93%, transparent), linear-gradient(to bottom, transparent, #000 16%, #000 84%, transparent); mask-composite: intersect; }
.w10lcut-copy { max-width: 32rem; margin: 8px auto 0; font-size: 18px; line-height: 1.6; color: #cfc8bb; }
.w10lcut-btn { display: inline-flex; align-items: center; height: 48px; margin-top: 32px; padding: 0 28px; border: 1px solid rgba(255,199,125,0.7); border-radius: 999px; color: #ffe2b3; font-size: 14px; font-weight: 600; text-decoration: none; }
.w10lcut-btn:hover { background: #ffc77d; color: #0d1016; }
.w10lcut-btn:focus-visible { outline: 2px solid #ffc77d; outline-offset: 3px; }
@media (max-width: 1024px) { .w10lcut-box { height: 260px; } }
@media (max-width: 640px) { .w10lcut-box { height: 170px; } }
/* Reduced motion is handled in the script: one still frame, no loop. */
@media (prefers-reduced-motion: reduce) { .w10lcut-btn { transition: none; } }
</style>
<script>
(function () {
/* Change these: letter light colours, number of ray layers (fewer is cheaper) and drift speed. */
var LIGHT = ["#fff1d6", "#ffc77d"];
var RAYS = 26;
var DRIFT = 0.00022;
var host = document.querySelector(".w10lcut-box");
if (!host) return;
var WORD = host.getAttribute("data-word") || "VESPER";
var canvas = host.querySelector("canvas");
var ctx = canvas.getContext("2d");
var mask = document.createElement("canvas");
var m = mask.getContext("2d");
var rays = document.createElement("canvas");
var r4 = rays.getContext("2d");
if (!ctx || !m || !r4) return;
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var w = 0, h = 0, frame = 0, visible = true, start = performance.now();
function font(s) { return "800 " + s + "px ui-sans-serif, system-ui, 'Helvetica Neue', Arial, sans-serif"; }
function build() {
var r = host.getBoundingClientRect();
w = Math.max(1, r.width); h = Math.max(1, r.height);
var dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr);
mask.width = Math.round(w); mask.height = Math.round(h);
rays.width = Math.max(1, Math.round(w / 3)); rays.height = Math.max(1, Math.round(h / 3));
var fs = h * 0.62;
m.font = font(fs);
var tw = m.measureText(WORD).width + fs * 0.08 * (WORD.length - 1);
if (tw > w * 0.84) fs *= (w * 0.84) / tw;
m.font = font(fs);
var sp = fs * 0.08;
var total = m.measureText(WORD).width + sp * (WORD.length - 1);
var g = m.createLinearGradient(0, h * 0.5 - fs * 0.4, 0, h * 0.5 + fs * 0.4);
g.addColorStop(0, LIGHT[0]); g.addColorStop(1, LIGHT[1]);
m.clearRect(0, 0, mask.width, mask.height);
m.fillStyle = g; m.textBaseline = "middle";
var x = (w - total) / 2;
for (var i = 0; i < WORD.length; i++) { m.fillText(WORD[i], x, h * 0.52); x += m.measureText(WORD[i]).width + sp; }
}
function draw(now) {
var t = now - start;
var lx = w / 2 + Math.cos(t * DRIFT) * w * 0.28;
var ly = h * 0.42 + Math.sin(t * DRIFT * 1.7) * h * 0.22;
var dpr = canvas.width / w, q = rays.width / w;
r4.setTransform(1, 0, 0, 1, 0, 0);
r4.clearRect(0, 0, rays.width, rays.height);
r4.globalCompositeOperation = "lighter";
for (var i = RAYS; i >= 1; i--) {
var s = 1 + i * 0.032;
r4.globalAlpha = 0.1 * (1 - i / (RAYS + 1));
r4.setTransform(q * s, 0, 0, q * s, q * lx * (1 - s), q * ly * (1 - s));
r4.drawImage(mask, 0, 0, w, h);
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
ctx.globalCompositeOperation = "source-over";
ctx.filter = "blur(5px)";
ctx.drawImage(rays, 0, 0, w, h);
ctx.filter = "none";
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.globalCompositeOperation = "source-over";
ctx.globalAlpha = 1;
ctx.shadowColor = "rgba(255, 190, 110, 0.65)";
ctx.shadowBlur = 24;
ctx.drawImage(mask, 0, 0, w, h);
ctx.shadowBlur = 0;
}
function loop(now) { draw(now); frame = requestAnimationFrame(loop); }
function play() { if (!frame && visible && !reduce) frame = requestAnimationFrame(loop); }
function stop() { cancelAnimationFrame(frame); frame = 0; }
build();
if (reduce) draw(start + 1200); else play();
new ResizeObserver(function () { build(); if (reduce) draw(start + 1200); }).observe(host);
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
if (visible) play(); else stop();
}).observe(host);
})();
</script>
With reduced motion: One still frame: the lit letters with their beams frozen at a gentle angle.
Running cost: About twenty-six scaled copies of a prepared text image blend into a third-size buffer each frame, then one blurred upscale; paused off screen.
A heavy outlined word on a soft dawn-peach background fills with teal water: the level rises over two seconds, then two wave layers keep rolling in opposite directions while a few bubbles drift upward inside the letters. Everything is one inline SVG with a text clip path, so it scales cleanly from a phone to a wide screen. It suits drinks, refill, wellness and progress messages.
<section class="w10lqf" data-paused="false">
<div class="w10lqf-inner">
<p class="w10lqf-eyebrow">Tidewell refill stations</p>
<h1 class="w10lqf-title">
<span class="w10lqf-sr">Refill</span>
<svg class="w10lqf-svg" aria-hidden="true" viewBox="0 0 1000 300">
<defs>
<clipPath id="w10lqf-clip">
<text x="500" y="262" text-anchor="middle" textLength="960" lengthAdjust="spacingAndGlyphs" font-size="270" font-weight="900" font-family="ui-sans-serif, system-ui, Arial, sans-serif">REFILL</text>
</clipPath>
</defs>
<text x="500" y="262" text-anchor="middle" textLength="960" lengthAdjust="spacingAndGlyphs" font-size="270" font-weight="900" font-family="ui-sans-serif, system-ui, Arial, sans-serif" fill="#fdf3ea" fill-opacity="0.55">REFILL</text>
<g clip-path="url(#w10lqf-clip)">
<g class="w10lqf-rise">
<g class="w10lqf-bob">
<g transform="translate(0 270)">
<path class="w10lqf-back" d="M0 12 Q125 -10 250 12 T500 12 T750 12 T1000 12 T1250 12 T1500 12 T1750 12 T2000 12 V400 H0 Z" />
<path class="w10lqf-front" d="M0 22 Q125 4 250 22 T500 22 T750 22 T1000 22 T1250 22 T1500 22 T1750 22 T2000 22 V400 H0 Z" />
<circle class="w10lqf-bubble" cx="180" cy="200" r="6" style="animation-delay: 0.2s" />
<circle class="w10lqf-bubble" cx="430" cy="210" r="4" style="animation-delay: 1.4s" />
<circle class="w10lqf-bubble" cx="610" cy="190" r="7" style="animation-delay: 2.3s" />
<circle class="w10lqf-bubble" cx="840" cy="205" r="5" style="animation-delay: 0.9s" />
</g>
</g>
</g>
</g>
<text class="w10lqf-outline" x="500" y="262" text-anchor="middle" textLength="960" lengthAdjust="spacingAndGlyphs" font-size="270" font-weight="900" font-family="ui-sans-serif, system-ui, Arial, sans-serif">REFILL</text>
</svg>
</h1>
<p class="w10lqf-copy">Bring any bottle to a Tidewell tap for filtered water, still or sparkling, chilled and paid by the litre.</p>
<a class="w10lqf-btn" href="#">Find a station near you</a>
</div>
</section>
<style>
/* Change these: the two water colours, the outline, and the fill level (more negative = fuller, -250px is the top). */
.w10lqf { --w10lqf-deep: #1d6f73; --w10lqf-light: #59b3ad; --w10lqf-outline: #1a4f53; --w10lqf-level: -170px; }
.w10lqf { background: linear-gradient(170deg, #fcefe4 0%, #f7dccd 55%, #f1cdbf 100%); padding: 80px 20px; font-family: ui-sans-serif, system-ui, sans-serif; text-align: center; }
.w10lqf-inner { max-width: 1024px; margin: 0 auto; }
.w10lqf-eyebrow { margin: 0; font-size: 12px; font-weight: 600; letter-spacing: 0.3em; text-transform: uppercase; color: #7a4a3a; }
.w10lqf-title { margin: 24px 0 0; }
.w10lqf-svg { display: block; width: 100%; max-width: 896px; height: auto; margin: 0 auto; }
.w10lqf-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.w10lqf-back { fill: var(--w10lqf-light); }
.w10lqf-front { fill: var(--w10lqf-deep); }
.w10lqf-bubble { fill: #e8fbf7; }
.w10lqf-outline { fill: none; stroke: var(--w10lqf-outline); stroke-width: 3; }
.w10lqf-copy { max-width: 32rem; margin: 24px auto 0; font-size: 18px; line-height: 1.6; color: #5a3a2f; }
.w10lqf-btn { display: inline-flex; align-items: center; height: 48px; margin-top: 32px; padding: 0 28px; border-radius: 999px; background: var(--w10lqf-outline); color: #fff; font-size: 14px; font-weight: 600; text-decoration: none; }
.w10lqf-btn:hover { background: var(--w10lqf-deep); }
.w10lqf-btn:focus-visible { outline: 2px solid var(--w10lqf-outline); outline-offset: 3px; }
.w10lqf-rise { transform: translateY(0); animation: w10lqf-rise 2.2s cubic-bezier(0.22, 1, 0.36, 1) 0.1s forwards; }
.w10lqf-bob { animation: w10lqf-bob 3.6s ease-in-out infinite; }
.w10lqf-front { animation: w10lqf-drift 4.5s linear infinite; }
.w10lqf-back { animation: w10lqf-drift 7s linear infinite reverse; }
.w10lqf-bubble { opacity: 0; transform-box: fill-box; animation: w10lqf-bubble 4s ease-in infinite; }
@keyframes w10lqf-rise { to { transform: translateY(var(--w10lqf-level)); } }
@keyframes w10lqf-bob { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(6px); } }
@keyframes w10lqf-drift { from { transform: translateX(0); } to { transform: translateX(-500px); } }
@keyframes w10lqf-bubble { 0% { opacity: 0; transform: translateY(0); } 15% { opacity: 0.8; } 85% { opacity: 0.6; } 100% { opacity: 0; transform: translateY(-120px); } }
.w10lqf[data-paused="true"] .w10lqf-bob, .w10lqf[data-paused="true"] .w10lqf-front, .w10lqf[data-paused="true"] .w10lqf-back, .w10lqf[data-paused="true"] .w10lqf-bubble { animation-play-state: paused; }
@media (prefers-reduced-motion: reduce) {
.w10lqf-rise { animation: none; transform: translateY(var(--w10lqf-level)); }
.w10lqf-bob, .w10lqf-front, .w10lqf-back, .w10lqf-bubble { animation: none; }
}
</style>
<script>
(function () {
var root = document.querySelector(".w10lqf");
if (!root || !("IntersectionObserver" in window)) return;
new IntersectionObserver(function (entries) {
root.setAttribute("data-paused", entries[0].isIntersecting ? "false" : "true");
}).observe(root);
})();
</script>
With reduced motion: The letters appear already filled to their final level, with still waves and no bubbles.
Running cost: Two wave paths and four bubbles move by transform inside one SVG clip, with no script beyond an off-screen pause.
Thousands of sand and amber specks drift in from a loose cloud and settle, left to right, into a large serif word on a dark roastery-brown hero. Once formed, the specks shimmer by less than a pixel, scatter away from a finger or cursor, and every eleven seconds loosen and gather again. It is built for a brand name or single word that deserves an entrance.
<section class="w10ptx">
<div class="w10ptx-glow" aria-hidden="true"></div>
<div class="w10ptx-inner">
<p class="w10ptx-eyebrow">Small-batch coffee roasters</p>
<h1 class="w10ptx-sr">Lantern</h1>
<div class="w10ptx-box" data-word="Lantern"><canvas class="w10ptx-canvas" aria-hidden="true"></canvas></div>
<p class="w10ptx-copy">Beans roasted every Tuesday in a converted boathouse, shipped the same afternoon so they reach you within the week.</p>
<a class="w10ptx-btn" href="#">Choose this week's roast</a>
</div>
</section>
<style>
.w10ptx { position: relative; overflow: hidden; background: #15110e; padding: 80px 20px; font-family: ui-sans-serif, system-ui, sans-serif; text-align: center; }
.w10ptx-glow { position: absolute; inset: 0; pointer-events: none; background: radial-gradient(55% 50% at 50% 42%, rgba(217,163,91,0.16), transparent 70%); }
.w10ptx-inner { position: relative; max-width: 1024px; margin: 0 auto; }
.w10ptx-eyebrow { margin: 0; font-size: 12px; font-weight: 600; letter-spacing: 0.32em; text-transform: uppercase; color: #c9b08a; }
.w10ptx-sr { position: absolute; width: 1px; height: 1px; margin: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.w10ptx-box { position: relative; height: 280px; margin: 16px auto 0; touch-action: pan-y; }
.w10ptx-canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
.w10ptx-copy { max-width: 32rem; margin: 16px auto 0; font-size: 18px; line-height: 1.6; color: #d8c6aa; }
.w10ptx-btn { display: inline-flex; align-items: center; height: 48px; margin-top: 32px; padding: 0 28px; border-radius: 999px; background: #efdcbc; color: #15110e; font-size: 14px; font-weight: 600; text-decoration: none; }
.w10ptx-btn:hover { background: #f7ecd8; }
.w10ptx-btn:focus-visible { outline: 2px solid #d9a35b; outline-offset: 3px; }
@media (max-width: 1024px) { .w10ptx-box { height: 240px; } }
@media (max-width: 640px) { .w10ptx-box { height: 150px; } }
/* Reduced motion is handled in the script: it draws the finished word once and never starts the loop. */
@media (prefers-reduced-motion: reduce) { .w10ptx-btn { transition: none; } }
</style>
<script>
(function () {
/* Change these: particle colours, settle time, loop length and the particle limit. */
var COLORS = ["#efdcbc", "#d9a35b", "#f7ecd8"];
var ASSEMBLE_MS = 1500, CYCLE_MS = 11000, MAX_DOTS = 4200;
var host = document.querySelector(".w10ptx-box");
if (!host) return;
var WORD = host.getAttribute("data-word") || "Lantern";
var canvas = host.querySelector("canvas");
var ctx = canvas.getContext("2d");
if (!ctx) return;
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var w = 0, h = 0, n = 0, tx, ty, sx, sy, ox, oy, delay, tone, size;
var frame = 0, visible = true, start = performance.now();
var pointer = { x: -1e4, y: -1e4 };
function build() {
var r = host.getBoundingClientRect();
w = Math.max(1, r.width); h = Math.max(1, r.height);
var dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
var off = document.createElement("canvas");
off.width = Math.ceil(w); off.height = Math.ceil(h);
var o = off.getContext("2d", { willReadFrequently: true });
var fs = h * 0.78;
o.font = "700 " + fs + "px ui-serif, Georgia, 'Times New Roman', serif";
var tw = o.measureText(WORD).width;
if (tw > w * 0.92) fs *= (w * 0.92) / tw;
o.font = "700 " + fs + "px ui-serif, Georgia, 'Times New Roman', serif";
o.textAlign = "center"; o.textBaseline = "middle";
o.fillText(WORD, w / 2, h * 0.53);
var data = o.getImageData(0, 0, off.width, off.height).data;
var gap = w < 640 ? 2.5 : 3.5, xs = [], ys = [];
for (var y = 0; y < off.height; y += gap) {
for (var x = 0; x < off.width; x += gap) {
if (data[(Math.floor(y) * off.width + Math.floor(x)) * 4 + 3] > 140) {
xs.push(x + (Math.random() - 0.5) * 0.8); ys.push(y + (Math.random() - 0.5) * 0.8);
}
}
}
var stride = Math.max(1, Math.ceil(xs.length / MAX_DOTS));
tx = Float32Array.from(xs.filter(function (_, i) { return i % stride === 0; }));
ty = Float32Array.from(ys.filter(function (_, i) { return i % stride === 0; }));
n = tx.length;
sx = new Float32Array(n); sy = new Float32Array(n); ox = new Float32Array(n); oy = new Float32Array(n);
delay = new Float32Array(n); tone = new Uint8Array(n); size = new Float32Array(n);
for (var i = 0; i < n; i++) {
var a = Math.random() * Math.PI * 2, d = 0.35 + Math.random() * 0.75;
sx[i] = w / 2 + Math.cos(a) * w * 0.55 * d;
sy[i] = h / 2 + Math.sin(a) * h * 0.9 * d;
delay[i] = (tx[i] / w) * 380 + Math.random() * 260;
var roll = Math.random();
tone[i] = roll < 0.62 ? 0 : roll < 0.9 ? 1 : 2;
size[i] = gap * (0.62 + Math.random() * 0.4);
}
}
function ease(p) { return 1 - Math.pow(1 - p, 3); }
function draw(now, still) {
var t = still ? ASSEMBLE_MS + 2000 : (now - start) % CYCLE_MS;
var out = CYCLE_MS - 1400;
ctx.clearRect(0, 0, w, h);
for (var c = 0; c < COLORS.length; c++) {
ctx.fillStyle = COLORS[c];
for (var i = 0; i < n; i++) {
if (tone[i] !== c) continue;
var p = Math.min(1, Math.max(0, (t - delay[i]) / (ASSEMBLE_MS - 600)));
if (t > out) p = 1 - Math.min(1, (t - out) / 1300) * 0.85;
var e = ease(p);
var x = sx[i] + (tx[i] - sx[i]) * e, y = sy[i] + (ty[i] - sy[i]) * e;
if (!still) {
x += Math.sin(now * 0.0011 + i) * 0.45;
y += Math.cos(now * 0.0013 + i * 1.7) * 0.45;
var dx = x - pointer.x, dy = y - pointer.y, d2 = dx * dx + dy * dy;
if (d2 < 3600) {
var f = (1 - d2 / 3600) * 2.2, dd = Math.sqrt(d2) || 1;
ox[i] += (dx / dd) * f; oy[i] += (dy / dd) * f;
}
ox[i] *= 0.9; oy[i] *= 0.9; x += ox[i]; y += oy[i];
}
ctx.globalAlpha = 0.35 + 0.65 * e;
ctx.fillRect(x, y, size[i], size[i]);
}
}
ctx.globalAlpha = 1;
}
function loop(now) { draw(now, false); frame = requestAnimationFrame(loop); }
function play() { if (!frame && visible && !reduce) frame = requestAnimationFrame(loop); }
function stop() { cancelAnimationFrame(frame); frame = 0; }
build();
if (reduce) draw(0, true); else play();
new ResizeObserver(function () { build(); if (reduce) draw(0, true); }).observe(host);
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
if (visible) play(); else stop();
}).observe(host);
host.addEventListener("pointermove", function (e) {
var r = host.getBoundingClientRect();
pointer.x = e.clientX - r.left; pointer.y = e.clientY - r.top;
});
host.addEventListener("pointerleave", function () { pointer.x = -1e4; pointer.y = -1e4; });
})();
</script>
With reduced motion: The finished word is drawn once from still particles, with no gathering, shimmer or pointer response.
Running cost: Up to 4,200 small squares redrawn per frame on a 2D canvas capped at 2x pixel density, paused when off screen.
A bakery hero on warm flour-white paper with a stamped seal beside the headline: an outer ring of bold capitals turns slowly clockwise while an inner ring of copper italics drifts the other way around a dark centre with a wheat ear. Both rings are SVG text on a circle, stretched with textLength so the words close up with no gap whatever font loads.
<section class="w10orb" data-paused="false">
<div class="w10orb-grid">
<div>
<p class="w10orb-eyebrow">Crumb & Copper Bakery</p>
<h1 class="w10orb-title">Bread worth crossing town for.</h1>
<p class="w10orb-copy">Long-fermented loaves baked through the night in a wood-fired oven, on the shelves before the first tram.</p>
<a class="w10orb-btn" href="#">Order for collection</a>
</div>
<div class="w10orb-badge">
<p class="w10orb-sr">Fresh bread daily, open from seven.</p>
<svg aria-hidden="true" viewBox="0 0 300 300">
<defs>
<path id="w10orb-ring-a" d="M150,150 m-118,0 a118,118 0 1,1 236,0 a118,118 0 1,1 -236,0" />
<path id="w10orb-ring-b" d="M150,150 m-86,0 a86,86 0 1,1 172,0 a86,86 0 1,1 -172,0" />
</defs>
<circle cx="150" cy="150" r="146" fill="#efe3d0" />
<circle cx="150" cy="150" r="138" fill="none" stroke="#9a4f2a" stroke-width="1" stroke-opacity="0.5" />
<circle cx="150" cy="150" r="102" fill="none" stroke="#9a4f2a" stroke-width="1" stroke-opacity="0.35" />
<g class="w10orb-outer">
<text fill="#2b211b" font-size="17" font-weight="600" letter-spacing="2" font-family="ui-sans-serif, system-ui, sans-serif"><textPath href="#w10orb-ring-a" textLength="735" lengthAdjust="spacing">FRESH BREAD DAILY · CRUMB & COPPER BAKERY · OPEN FROM SEVEN · </textPath></text>
</g>
<g class="w10orb-inner">
<text fill="#9a4f2a" font-size="12" font-weight="500" letter-spacing="1.5" font-family="ui-serif, Georgia, serif" font-style="italic"><textPath href="#w10orb-ring-b" textLength="535" lengthAdjust="spacing">SOURDOUGH · RYE · FOCACCIA · BRIOCHE · SEEDED · </textPath></text>
</g>
<circle cx="150" cy="150" r="66" fill="#2b211b" />
<g fill="none" stroke="#f5eee2" stroke-width="2.2" stroke-linecap="round">
<path d="M150 116 V176" />
<path d="M150 128 C140 124 134 116 134 108 C142 110 148 118 150 128" />
<path d="M150 128 C160 124 166 116 166 108 C158 110 152 118 150 128" />
<path d="M150 144 C140 140 134 132 134 124 C142 126 148 134 150 144" />
<path d="M150 144 C160 140 166 132 166 124 C158 126 152 134 150 144" />
<path d="M150 160 C140 156 134 148 134 140 C142 142 148 150 150 160" />
<path d="M150 160 C160 156 166 148 166 140 C158 142 152 150 150 160" />
</g>
<text x="150" y="196" text-anchor="middle" fill="#e6c6a8" font-size="10" letter-spacing="2.5" font-family="ui-sans-serif, system-ui, sans-serif">EST. 2014</text>
</svg>
</div>
</div>
</section>
<style>
/* Change these: accent colour, ink colour and one full turn of the outer ring (the inner ring takes 1.5 times as long). */
.w10orb { --w10orb-copper: #9a4f2a; --w10orb-ink: #2b211b; --w10orb-turn: 36s; }
.w10orb { background: #f5eee2; padding: 80px 20px; font-family: ui-sans-serif, system-ui, sans-serif; }
.w10orb-grid { display: grid; gap: 40px; align-items: center; max-width: 1024px; margin: 0 auto; }
@media (min-width: 768px) { .w10orb-grid { grid-template-columns: 1.1fr 1fr; gap: 64px; } }
.w10orb-eyebrow { margin: 0; font-size: 12px; font-weight: 600; letter-spacing: 0.3em; text-transform: uppercase; color: var(--w10orb-copper); }
.w10orb-title { margin: 20px 0 0; font-family: ui-serif, Georgia, serif; font-size: clamp(40px, 6vw, 68px); font-weight: 600; line-height: 1.05; letter-spacing: -0.02em; color: var(--w10orb-ink); }
.w10orb-copy { max-width: 28rem; margin: 24px 0 0; font-size: 18px; line-height: 1.6; color: #5a4a3e; }
.w10orb-btn { display: inline-flex; align-items: center; height: 48px; margin-top: 32px; padding: 0 28px; border-radius: 999px; background: var(--w10orb-ink); color: #f5eee2; font-size: 14px; font-weight: 600; text-decoration: none; }
.w10orb-btn:hover { background: var(--w10orb-copper); }
.w10orb-btn:focus-visible { outline: 2px solid var(--w10orb-copper); outline-offset: 3px; }
.w10orb-badge { position: relative; width: 100%; max-width: 340px; margin: 0 auto; }
.w10orb-badge svg { display: block; width: 100%; height: auto; }
.w10orb-sr { position: absolute; width: 1px; height: 1px; margin: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.w10orb-outer { transform-origin: 150px 150px; animation: w10orb-spin var(--w10orb-turn) linear infinite; }
.w10orb-inner { transform-origin: 150px 150px; animation: w10orb-spin calc(var(--w10orb-turn) * 1.5) linear infinite reverse; }
@keyframes w10orb-spin { to { transform: rotate(360deg); } }
.w10orb[data-paused="true"] .w10orb-outer, .w10orb[data-paused="true"] .w10orb-inner { animation-play-state: paused; }
@media (prefers-reduced-motion: reduce) { .w10orb-outer, .w10orb-inner { animation: none; } }
</style>
<script>
(function () {
var root = document.querySelector(".w10orb");
if (!root || !("IntersectionObserver" in window)) return;
new IntersectionObserver(function (entries) {
root.setAttribute("data-paused", entries[0].isIntersecting ? "false" : "true");
}).observe(root);
})();
</script>
With reduced motion: Both rings of lettering stay still, so the badge reads like a printed stamp.
Running cost: Two SVG groups rotate by transform on slow linear loops, with no script beyond an off-screen pause.
The canvas pieces draw the word once to an offscreen canvas to find where the letters are, then animate only the points or strokes they need. They cap the pixel ratio and stop their frame loop when off screen, so they cost nothing once the reader has moved on.
SVG stroke drawing animates stroke-dashoffset, which repaints only the path being drawn. Long pen-drawn sentences grow in cost with their length, so keep them to a word or a short phrase.
No, a canvas is a picture to them. That is why each canvas effect here also keeps the words as real HTML text, visually hidden or layered underneath, so they are indexed and read aloud.
Draw the word as SVG paths with a stroke, set stroke-dasharray to each path's length and animate stroke-dashoffset from that length to zero. Paths drawn in writing order look like a pen moving.
Not when they animate cheap properties and stop when done. Problems come from effects that keep re-laying out text every frame or keep running off screen; these do neither.
Pick the one whose motion matches what the business does, and use it once. A florist suits a pen-drawn word, a tech launch suits particles, a drinks brand suits a liquid fill.
More collections: CSS Text Animations, Text Reveal and Scramble Animations, Glitch Text Effects in CSS, Number Counter Animations. 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.