Letter stagger
Letters enter one after another, words stay whole and never break across lines. Screen readers hear the text once.
One of 5 components in Text effects
Variants
Details
What Letter stagger brings along, read from the registry and the source.
- Category
- Text effects
- Area
- Elements
- Variants
- 3
- Version
- 1.0.0 · since 1.0.0
- File
- components/ui/letter-stagger.tsx
- Brings along
- utilskit
- npm packages
- None
- Languages
- Texts come through props
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The LetterStaggerProps interface from components/ui/letter-stagger.tsx, with the comments from the code.
textrequiredstringThe full text. Words are kept whole, letters only move inside them.
asoptionalTagh1h2h3h4pspandiv
Element to render, keeps the heading level. Default p.
effectoptionalLetterStaggerEffectrisedropblurscale
rise: up from below (default). drop: down from above. blur: sharpens in place. scale: grows from small.
triggeroptionalLetterStaggerTriggerloadviewin-viewhoverscroll
load: right away. view (or in-view): when it scrolls in (default). hover: again on every hover and focus. scroll: the letters arrive with the scroll position and leave again when you scroll back up.
scrollRangeoptional[number, number]With trigger scroll: the part of the way through the window that brings the letters in, 0 to 1. Default [0.06, 0.46].
staggeroptionalnumberMilliseconds between two letters. Default 32.
durationoptionalnumberMilliseconds one letter takes. Default 700.
delayoptionalnumberMilliseconds before the first letter. Default 0.
highlightoptionalstringA phrase inside text that takes the accent color
Accessibility
Read from the source of the component.
- Adds text for screen readers where icons and graphics stand alone.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Moves with the scroll, its progress follows the position on the page.
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 191 lines
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round } from '@/lib/kit';
export type LetterStaggerEffect = 'rise' | 'drop' | 'blur' | 'scale';
/** in-view is the same as view */
export type LetterStaggerTrigger = 'load' | 'view' | 'in-view' | 'hover' | 'scroll';
type Tag = 'h1' | 'h2' | 'h3' | 'h4' | 'p' | 'span' | 'div';
export interface LetterStaggerProps {
/** The full text. Words are kept whole, letters only move inside them. */
text: string;
/** Element to render, keeps the heading level. Default p. */
as?: Tag;
/** rise: up from below (default). drop: down from above. blur: sharpens in place. scale: grows from small. */
effect?: LetterStaggerEffect;
/**
* load: right away. view (or in-view): when it scrolls in (default).
* hover: again on every hover and focus. scroll: the letters arrive with
* the scroll position and leave again when you scroll back up.
*/
trigger?: LetterStaggerTrigger;
/** With trigger scroll: the part of the way through the window that brings the letters in, 0 to 1. Default [0.06, 0.46]. */
scrollRange?: [number, number];
/** Milliseconds between two letters. Default 32. */
stagger?: number;
/** Milliseconds one letter takes. Default 700. */
duration?: number;
/** Milliseconds before the first letter. Default 0. */
delay?: number;
/** A phrase inside text that takes the accent color */
highlight?: string;
/** Classes for the element, e.g. text-display, text-h1, text-h2 */
className?: string;
}
const CSS = `
.kitfx-ls-w{white-space:nowrap}
.kitfx-ls-l{display:inline-block;padding-block:.1em;margin-block:-.1em}
.kitfx-ls[data-mode=play] .kitfx-ls-l{animation:var(--kitfx-ls-name) var(--kitfx-ls-dur) cubic-bezier(.2,.75,.25,1) both;animation-delay:calc(var(--kitfx-ls-delay) + var(--i) * var(--kitfx-ls-stagger))}
.kitfx-ls[data-mode=wait] .kitfx-ls-l{opacity:0}
.kitfx-ls[data-mode=scroll] .kitfx-ls-l{--t:clamp(0,(var(--kitfx-p,1) * (var(--n) + var(--w)) - var(--i)) / var(--w),1);opacity:var(--t);transition:opacity .2s linear,transform .2s linear,filter .2s linear}
.kitfx-ls[data-mode=scroll][data-effect=rise] .kitfx-ls-l{transform:translateY(calc((1 - var(--t)) * .55em))}
.kitfx-ls[data-mode=scroll][data-effect=drop] .kitfx-ls-l{transform:translateY(calc((var(--t) - 1) * .5em))}
.kitfx-ls[data-mode=scroll][data-effect=blur] .kitfx-ls-l{filter:blur(calc((1 - var(--t)) * 8px))}
.kitfx-ls[data-mode=scroll][data-effect=scale] .kitfx-ls-l{transform:scale(calc(.7 + .3 * var(--t)))}
.kitfx-ls[data-effect=rise]{--kitfx-ls-name:kitfx-ls-rise}
.kitfx-ls[data-effect=drop]{--kitfx-ls-name:kitfx-ls-drop}
.kitfx-ls[data-effect=blur]{--kitfx-ls-name:kitfx-ls-blur}
.kitfx-ls[data-effect=scale]{--kitfx-ls-name:kitfx-ls-scale}
@keyframes kitfx-ls-rise{from{opacity:0;transform:translateY(.55em)}}
@keyframes kitfx-ls-drop{from{opacity:0;transform:translateY(-.5em)}}
@keyframes kitfx-ls-blur{from{opacity:0;filter:blur(8px)}}
@keyframes kitfx-ls-scale{from{opacity:0;transform:scale(.7)}}
@keyframes kitfx-ls-fade{from{opacity:0}}
@media (prefers-reduced-motion:reduce){.kitfx-ls[data-mode] .kitfx-ls-l{animation:kitfx-ls-fade .5s ease both;opacity:1;transform:none;filter:none}}`;
/** Plays a CSS entrance from the first paint, holds lines further down until they scroll in. */
function usePlay<T extends HTMLElement>(trigger: LetterStaggerTrigger) {
const ref = useRef<T>(null);
const [run, setRun] = useState(0);
const [mode, setMode] = useState<'play' | 'wait' | 'idle' | 'scroll'>(trigger === 'hover' ? 'idle' : trigger === 'scroll' ? 'scroll' : 'play');
useEffect(() => {
const el = ref.current;
if (!el || (trigger !== 'view' && trigger !== 'in-view') || prefersReducedMotion()) return;
if (el.getBoundingClientRect().top < window.innerHeight * 0.9) return;
setMode('wait');
const io = new IntersectionObserver(
([e]) => {
if (!e?.isIntersecting) return;
setMode('play');
setRun((r) => r + 1);
io.disconnect();
},
{ rootMargin: '0px 0px -10% 0px' },
);
io.observe(el);
return () => io.disconnect();
}, [trigger]);
const replay = useCallback(() => {
if (prefersReducedMotion()) return;
setMode('play');
setRun((r) => r + 1);
}, []);
return { ref, run, mode, replay };
}
/**
* Scroll link. Writes --kitfx-p (0 to 1) on the element: 0 when it has
* travelled `start` of its way through the window, 1 at `end` (0 is its top
* at the bottom edge, 1 its bottom at the top edge). The value is scaled to
* the part of that way the page can still scroll, so a line at the end of a
* short page still finishes. Never with reduced motion, then the CSS
* fallback 1 keeps the final state.
*/
function useScrollLink(ref: { current: HTMLElement | null }, on: boolean, range: readonly [number, number], onChange?: (p: number) => void) {
const [start, end] = range;
const listener = useRef(onChange);
listener.current = onChange;
useEffect(() => {
const el = ref.current;
if (!el || !on || prefersReducedMotion()) return;
let frame = 0;
const span = Math.max(0.0001, end - start);
const at = (top: number, h: number, vh: number) => Math.min(1, Math.max(0, ((vh - top) / (h + vh) - start) / span));
const update = () => {
frame = 0;
const r = el.getBoundingClientRect();
const vh = window.innerHeight;
const rest = Math.max(0, document.documentElement.scrollHeight - vh - window.scrollY);
const most = at(r.top - rest, r.height, vh);
const p = most < 0.02 ? 1 : Math.min(1, at(r.top, r.height, vh) / most);
el.style.setProperty('--kitfx-p', p.toFixed(4));
listener.current?.(p);
};
const onScroll = () => {
if (!frame) frame = requestAnimationFrame(update);
};
update();
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener('scroll', onScroll);
window.removeEventListener('resize', onScroll);
el.style.removeProperty('--kitfx-p');
};
}, [ref, on, start, end]);
}
/** Graphemes, so an emoji or an accent made of two code points stays one letter. */
function letters(word: string): string[] {
const Seg = (Intl as unknown as { Segmenter?: new (l?: string, o?: { granularity: string }) => { segment: (s: string) => Iterable<{ segment: string }> } }).Segmenter;
if (Seg) return Array.from(new Seg(undefined, { granularity: 'grapheme' }).segment(word), (s) => s.segment);
return Array.from(word);
}
/**
* Letters enter one after another. Each word stays one unit that never
* breaks across lines, punctuation stays with its word. Screen readers get
* the text once, the letters are hidden from them. Without JavaScript and
* with reduced motion the text stands complete.
* <LetterStagger as="h2" className="text-display" text="Café Mira" />
*/
export function LetterStagger({ text, as: Tag = 'p', effect = 'rise', trigger = 'view', scrollRange = [0.06, 0.46], stagger = 32, duration = 700, delay = 0, highlight, className }: LetterStaggerProps) {
const { ref, run, mode, replay } = usePlay<HTMLElement>(trigger);
useScrollLink(ref, trigger === 'scroll', scrollRange);
const clean = text.trim();
const at = highlight ? clean.indexOf(highlight) : -1;
const end = at >= 0 ? at + highlight!.length : -1;
const hover = trigger === 'hover' ? { onPointerEnter: replay, onFocus: replay } : {};
let index = 0;
const words = Array.from(clean.matchAll(/\S+/g), (m) => {
let pos = m.index ?? 0;
return letters(m[0]).map((ch) => {
const p = pos;
pos += ch.length;
return { ch, i: index++, accent: at >= 0 && p >= at && p < end, p };
});
});
const vars = { '--kitfx-ls-stagger': `${stagger}ms`, '--kitfx-ls-dur': `${duration}ms`, '--kitfx-ls-delay': `${delay}ms`, '--n': index, '--w': Math.max(3, Math.round(index * 0.2)) } as React.CSSProperties;
// The accent is one gradient over the whole phrase, every letter shows its slice of it.
const accentStyle = (p: number): React.CSSProperties => {
const n = end - at;
if (n <= 1) return {};
return { backgroundSize: `${round(n * 100)}% 100%`, backgroundPosition: `${round(((p - at) / (n - 1)) * 100)}% 0` };
};
return (
<Tag ref={ref as never} className={cn('kitfx-ls', className)} data-mode={mode} data-effect={effect} style={vars} {...hover}>
<style dangerouslySetInnerHTML={{ __html: CSS }} />
<span className="sr-only">{text}</span>
<span aria-hidden key={run}>
{words.map((w, wi) => (
<span key={wi}>
{wi > 0 && ' '}
<span className="kitfx-ls-w">
{w.map((x) => (
<span key={x.i} className={cn('kitfx-ls-l', x.accent && 'kit-accent-text')} style={{ '--i': x.i, ...(x.accent ? accentStyle(x.p) : {}) } as React.CSSProperties}>
{x.ch}
</span>
))}
</span>
</span>
))}
</span>
</Tag>
);
}Frequent questions
How do I install Letter stagger?
With aiw add @ai-website/letter-stagger. The command puts components/ui/letter-stagger.tsx into your project, the code is yours from then on. It needs no further npm packages.
Which variants does Letter stagger have?
3 variants, Rise, Blur on scroll and Scale.
Can an AI assistant build with Letter stagger?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Letter stagger with aiw_components and install it with aiw_add @ai-website/letter-stagger.