Generating letter wave
A status label whose letters ride a wave while AI works, then settle in order into a drawn check.
It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.
Demo.tsx
"use client";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { OUT, useInView, usePrefersReducedMotion } from "@/lib/motion";
import styles from "./demo.module.css";
const WORKING = "Generating meeting notes";
const DONE = "Notes ready";
const NOTES = ["Ship the pricing page by Friday", "Maya owns the onboarding email", "Recheck churn numbers next sync"];
type Phase = "working" | "settle" | "ready";
// How long each phase lasts before the next one; ready loops back to working on its own.
const WAIT: Record<Phase, number> = { working: 3000, settle: 650, ready: 3600 };
export default function Demo() {
const root = useRef<HTMLDivElement>(null);
const letters = useRef<HTMLSpanElement>(null);
const lifted = useRef<number[]>([]);
const inView = useInView(root);
const reduced = usePrefersReducedMotion();
const [phase, setPhase] = useState<Phase>("working");
useEffect(() => {
if (reduced) setPhase("ready");
}, [reduced]);
useEffect(() => {
if (!inView) return;
if (reduced && phase === "ready") return;
const timer = setTimeout(() => {
if (phase === "working") {
// Remember where each letter is mid bob, so the settle starts from there instead of snapping.
const spans = letters.current ? [...letters.current.children] : [];
lifted.current = spans.map((s) => new DOMMatrix(getComputedStyle(s).transform).m42);
setPhase(reduced ? "ready" : "settle");
} else setPhase(phase === "settle" ? "ready" : "working");
}, reduced ? 1200 : WAIT[phase]);
return () => clearTimeout(timer);
}, [phase, inView, reduced]);
// The CSS wave is off in the settle phase; each letter glides down from where it was, left to right.
useLayoutEffect(() => {
const el = letters.current;
if (phase !== "settle" || !el) return;
const runs = [...el.children].map((s, i) =>
s.animate(
[{ transform: `translateY(${lifted.current[i] ?? 0}px)` }, { transform: "none" }],
{ duration: 380, delay: i * 18, easing: OUT, fill: "backwards" },
),
);
return () => runs.forEach((r) => r.cancel());
}, [phase]);
const ready = phase === "ready";
return (
<div ref={root} className={styles.root} data-phase={phase}>
<section className={styles.card} aria-label="Meeting notes">
<header className={styles.head}>
<span className={styles.icon} aria-hidden="true">
{ready ? <svg viewBox="0 0 16 16"><path d="M3.5 8.5l3 3 6-7" /></svg> : <span className={styles.spinner} />}
</span>
<p className={styles.status} role="status">
{ready ? (
<span key="done" className={styles.done}>{DONE}</span>
) : (
<>
<span className={styles.sr}>{WORKING}</span>
<span ref={letters} className={styles.letters} aria-hidden="true">
{[...WORKING].map((c, i) => (
<span key={i} className={styles.ch} style={{ ["--i" as string]: i }}>{c === " " ? " " : c}</span>
))}
</span>
</>
)}
</p>
</header>
<ul className={styles.notes}>
{NOTES.map((n, i) => (
<li key={n} style={{ ["--i" as string]: i }}>
{ready ? <span className={styles.note}>{n}</span> : <span className={styles.bar} aria-hidden="true" />}
</li>
))}
</ul>
<button type="button" className={styles.again} onClick={() => setPhase("working")} disabled={!ready}>
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M13 8a5 5 0 1 1-1.6-3.7M13 2.5v3h-3" /></svg>
Regenerate
</button>
</section>
</div>
);
}
demo.module.css
.root {
container-type: size; display: grid; place-items: center; width: 100%; height: 100%; padding: 16px;
box-sizing: border-box; background: var(--surface);
}
.card {
width: min(380px, 100%); display: grid; gap: 14px; padding: 16px 18px; box-sizing: border-box;
border-radius: 16px; border: 1px solid var(--line); background: var(--white);
}
.head { display: flex; align-items: center; gap: 10px; min-height: 22px; }
.status { margin: 0; font-size: 14px; font-weight: 650; white-space: nowrap; }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
/* The wave: every letter bobs on the same loop, offset by its index, and an accent colour sweeps along a beat behind. */
.letters { display: inline-flex; }
.ch {
display: inline-block; color: var(--muted);
animation: bob 1.1s ease-in-out calc(var(--i) * 55ms - 2.2s) infinite, sweep 1.8s linear calc(var(--i) * 45ms) infinite;
}
@keyframes bob { 0%, 55%, 100% { transform: none; } 25% { transform: translateY(-3.5px); } }
@keyframes sweep { 0%, 100% { color: var(--muted); } 12% { color: var(--accent-text); } 30% { color: var(--muted); } }
.root[data-phase="settle"] .ch { animation: none; color: var(--ink); transition: color .4s var(--out) calc(var(--i) * 18ms); }
.done { display: inline-block; color: var(--ink); animation: rise .4s var(--out); }
@keyframes rise { from { opacity: 0; transform: translateY(5px); } }
.icon { position: relative; display: grid; place-items: center; flex: none; width: 20px; height: 20px; border-radius: 50%; }
.spinner { width: 12px; height: 12px; border-radius: 50%; border: 2px solid var(--line); border-top-color: var(--accent-text); box-sizing: border-box; animation: spin .7s linear infinite; }
.root[data-phase="ready"] .icon { background: var(--accent-pale); color: var(--accent-text); animation: pop .35s var(--out); }
.icon svg { width: 13px; height: 13px; fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 16; animation: draw .45s var(--out) .1s both; }
@keyframes spin { to { rotate: 1turn; } }
@keyframes pop { from { scale: .6; } }
@keyframes draw { from { stroke-dashoffset: 16; } }
/* Note lines: soft placeholder bars while it works, real bullets fading in once ready. */
.notes { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
.notes li { display: flex; align-items: center; gap: 8px; min-height: 18px; font-size: 13px; color: var(--ink); }
.notes li::before { content: ""; flex: none; width: 5px; height: 5px; border-radius: 50%; background: var(--line); transition: background-color .3s; }
.root[data-phase="ready"] .notes li::before { background: var(--accent); }
.bar {
height: 8px; border-radius: 4px; width: calc(92% - var(--i) * 18%);
background: linear-gradient(90deg, var(--surface) 30%, var(--accent-pale) 50%, var(--surface) 70%) 0 0 / 300% 100%;
animation: shimmer 1.6s linear calc(var(--i) * 150ms) infinite;
}
@keyframes shimmer { from { background-position: 100% 0; } to { background-position: 0 0; } }
.note { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; animation: rise .45s var(--out) calc(var(--i) * 90ms) both; }
.again {
justify-self: start; display: inline-flex; align-items: center; gap: 6px; min-height: 36px; padding: 0 14px 0 12px;
border-radius: 999px; border: 1px solid var(--line); background: var(--white); color: var(--ink);
font: inherit; font-size: 13px; font-weight: 650; cursor: pointer; transition: opacity .3s, transform .18s var(--out), border-color .2s;
}
.again svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.again:hover:not(:disabled) { border-color: var(--accent-soft); }
.again:active:not(:disabled) { transform: scale(.96); }
.again:disabled { opacity: .45; cursor: default; }
.again:focus-visible { outline: 2px solid var(--accent-text); outline-offset: 2px; }
@container (max-height: 250px) {
.card { gap: 10px; padding: 12px 16px; }
.notes { gap: 6px; }
}
@container (max-width: 300px) {
.status { font-size: 13px; }
}
@media (prefers-reduced-motion: reduce) {
.ch, .bar, .spinner, .done, .note, .icon svg, .root[data-phase="ready"] .icon { animation: none; }
.ch { color: var(--ink); }
}
Use it for
- AI features that take a few seconds to produce a result
- Summaries, notes or drafts that appear after a wait
- Replacing a bare spinner with a label that says what is happening
Avoid
- Long labels, the wave turns into noise past a few words
- Waits under a second, where the settle never gets to play
How it works
Each letter is its own span running one shared CSS bob keyframe with a negative delay from its index, so the wave travels along the label; a second keyframe sweeps the accent colour a beat behind. When work finishes, the code reads each letter's current offset from its computed transform, turns the CSS wave off and lets the Web Animations API glide every letter down from there with a small stagger. The label then swaps to Notes ready with a stroke drawn check.
Accessibility
The status line is a live region holding the plain label, while the animated letters are aria-hidden. Regenerate is a real button. Reduced motion stops the wave, spinner and shimmer and shows the static labels.