Text Animationsv0.4.0New
Misprint Text
A paragraph that prints like a misregistered press, with stickers and glitches.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/misprint-text.jsonManual
Install the dependencies
npm install motionAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/random.ts/** * Seeded randomness for visuals: dot layouts, characters' moods, print glitches. The same seed * gives the same sequence on the server and in the first client frame, so nothing shifts or * mismatches on hydration. Not for anything security-related. */ /** A 32-bit hash of a string or number, to turn a `seed` prop into a generator state. */ export function hashSeed(seed: string | number): number { const text = typeof seed === "number" ? `n${seed}` : seed; // FNV-1a, then a final avalanche so short, similar seeds still diverge. let h = 0x811c9dc5; for (let i = 0; i < text.length; i++) { h ^= text.charCodeAt(i); h = Math.imul(h, 0x01000193); } h ^= h >>> 16; h = Math.imul(h, 0x7feb352d); h ^= h >>> 15; return h >>> 0; } /** mulberry32: a small, fast generator of floats in [0, 1). */ export function mulberry32(state: number): () => number { let a = state >>> 0; return () => { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } export interface Random { /** A float in [0, 1). */ next(): number; /** A float in [min, max). */ range(min: number, max: number): number; /** An integer from min to max, both included. */ int(min: number, max: number): number; /** True with probability `p`. */ chance(p: number): boolean; /** One item of a non-empty list. */ pick<T>(list: readonly T[]): T; /** An independent generator derived from this seed and a salt, e.g. one per word or per coin. */ fork(salt: string | number): Random; } /** A seeded generator with a few helpers on top of `mulberry32`. */ export function createRandom(seed: string | number): Random { const base = hashSeed(seed); const next = mulberry32(base); return { next, range: (min, max) => min + next() * (max - min), int: (min, max) => min + Math.floor(next() * (max - min + 1)), chance: (p) => next() < p, pick: (list) => list[Math.floor(next() * list.length)], fork: (salt) => createRandom(`${base}:${salt}`), }; }Copy the source
components/ui/misprint-text/misprint-text.tsx"use client"; import { Fragment, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, type ComponentProps, type CSSProperties, type Ref } from "react"; import { useReducedMotion } from "motion/react"; import { createRandom, type Random } from "@/lib/random"; export interface MisprintPalette { /** Cyan plate. */ cyan: string; /** Magenta plate. */ magenta: string; /** Yellow plate. */ yellow: string; /** Sticker label colors, picked per sticker. */ stickers: string[]; /** Sticker outline and hard shadow. */ outline: string; } export interface MisprintTextHandle { /** Scatters the paragraph, leaves a few stray words, then prints it again from the start. */ replay: () => void; } export interface MisprintTextProps extends Omit<ComponentProps<"p">, "children" | "ref"> { /** The paragraph. Words are split on whitespace and stay real, selectable text. */ text: string; /** The same seed always gives the same print, stickers and glitches. @default "misprint" */ seed?: string | number; /** Print word by word the first time the text scrolls into view. Off shows it printed until `replay()`. @default true */ autoplay?: boolean; /** Small flicks, bursts and the odd stuck band after printing. @default true */ idleGlitch?: boolean; /** Share of words that land as a stack of sticker labels, 0–1. @default 0.125 */ stickers?: number; /** Print speed multiplier. @default 1 */ speed?: number; /** Plates multiply on light paper or screen on dark paper. Text color comes from the parent. @default "light" */ tone?: "light" | "dark"; /** Plate and sticker colors. */ palette?: Partial<MisprintPalette>; /** Imperative handle with `replay()`. */ ref?: Ref<MisprintTextHandle>; } const PALETTES: Record<"light" | "dark", MisprintPalette> = { light: { cyan: "#00a0d8", magenta: "#e5007d", yellow: "#ffd100", stickers: ["#ffd100", "#ff3d9a", "#2f6bff", "#22b573"], outline: "#111111" }, dark: { cyan: "#2ad4ff", magenta: "#ff4fa8", yellow: "#ffe14a", stickers: ["#ffd100", "#ff3d9a", "#3d7bff", "#22c07a"], outline: "#000000" }, }; /** Ticks per second: the print steps like a press, not a smooth tween. */ const TPS = 24; /** How long a word's arrival effects last, in ticks. */ const LIFE = 16; type Kind = "shatter" | "ghost" | "sticker"; interface WordPlan { at: number; kind: Kind; pieces: number; ghosts: number; stickers: number; peelAt: number; interlace: boolean; debris: number; rng: Random; } interface Glitch { word: number; type: "flick" | "slip" | "ghost"; start: number; length: number; } interface Tear { start: number; length: number; top: number; height: number; shift: number; split: boolean; } const ease = (t: number) => 1 - (1 - Math.min(1, Math.max(0, t))) ** 3; /** * A paragraph that prints like a misregistered press. Words land a few ticks apart: some shatter * into flat-colored pieces that settle, some arrive as offset colored duplicates that snap on, and * about one in eight lands as a stack of skewed sticker labels that jitter and peel away. Cyan, * magenta and yellow plates close onto the key text, some words slip in strips, ink debris fades * around fresh words and now and then a band of the paragraph tears sideways. After printing it * glitches occasionally; `replay()` scatters it and prints it again. Seeded, so every print of the * same seed is identical. * * The words are real text (selectable, read once by screen readers); every effect is an aria-hidden * layer driven by one stepped rAF loop. Reduced motion and no-script both show the printed text. */ export function MisprintText({ text, seed = "misprint", autoplay = true, idleGlitch = true, stickers = 0.125, speed = 1, tone = "light", palette, ref, className, style, ...props }: MisprintTextProps) { const reduceMotion = useReducedMotion(); const rootRef = useRef<HTMLParagraphElement>(null); const bodyRef = useRef<HTMLSpanElement>(null); const tearRef = useRef<HTMLSpanElement>(null); const engine = useRef<{ replay: () => void; wake: () => void } | null>(null); const words = useMemo(() => text.split(/\s+/).filter(Boolean), [text]); const colors = { ...PALETTES[tone], ...palette }; // Read by the loop each tick, so changing them never restarts the print. const settings = useRef({ speed, idleGlitch, colors, tone }); useEffect(() => { settings.current = { speed, idleGlitch, colors, tone }; engine.current?.wake(); }); useImperativeHandle(ref, () => ({ replay: () => engine.current?.replay() }), []); // Hide unprinted words before the first paint; without scripts (or under reduced motion) they stay visible. useLayoutEffect(() => { if (!autoplay || reduceMotion) return; bodyRef.current?.querySelectorAll<HTMLElement>("[data-k]").forEach((el) => (el.style.opacity = "0")); }, [autoplay, reduceMotion, words]); useEffect(() => { const root = rootRef.current; const body = bodyRef.current; const tearLayer = tearRef.current; if (!root || !body || !tearLayer) return; const pal = () => settings.current.colors; // Each word is a box (moved as a whole when scattering) holding the key text and its effect layers. const boxes = [...body.querySelectorAll<HTMLElement>("[data-w]")]; const keys = boxes.map((el) => el.querySelector<HTMLElement>("[data-k]")!); const fxs = boxes.map((el) => el.querySelector<HTMLElement>("[data-fx]")!); const tearWords = [...tearLayer.querySelectorAll<HTMLElement>("[data-t]")]; const plates = () => [pal().cyan, pal().magenta, pal().yellow]; // Solid pieces and duplicates skip yellow on light paper, where it would all but disappear. const inks = () => (settings.current.tone === "dark" ? plates() : [pal().cyan, pal().magenta]); const showAll = () => { keys.forEach((el, i) => { el.style.opacity = "1"; boxes[i].style.transform = ""; fxs[i].replaceChildren(); tearWords[i].style.opacity = "1"; }); body.style.clipPath = ""; tearLayer.style.visibility = "hidden"; }; if (reduceMotion) { showAll(); engine.current = { replay: () => {}, wake: () => {} }; return () => { engine.current = null; }; } /* ------------------------------------------------------------ plan (seeded) */ const rng = createRandom(seed); let cursor = 0; const plan: WordPlan[] = words.map((word, i) => { const r = rng.fork(`w${i}`); const at = cursor; cursor += 1 + r.int(0, 2); // Small pauses at commas and sentence ends. if (/[,;:]$/.test(word)) cursor += 4; if (/[.!?]["')\]]*$/.test(word)) cursor += 8; const kind: Kind = r.next() < stickers ? "sticker" : r.chance(0.55) ? "shatter" : "ghost"; return { at, kind, pieces: r.int(3, 8), ghosts: r.int(1, 2), stickers: r.int(1, 3), peelAt: 3 + r.int(0, 1), interlace: r.chance(0.16), debris: r.int(1, 4), rng: r }; }); const printLength = cursor + LIFE; const tearRng = rng.fork("tears"); const tears: Tear[] = Array.from({ length: tearRng.int(2, 4) }, () => makeTear(tearRng, tearRng.int(4, Math.max(5, cursor - 4)), tearRng.int(3, 6))); /* ------------------------------------------------------------ effects (DOM) */ const layer = (i: number, css: Partial<CSSStyleDeclaration>, content = words[i]) => { const el = document.createElement("span"); el.textContent = content; Object.assign(el.style, { position: "absolute", left: "0", top: "0", whiteSpace: "nowrap", pointerEvents: "none", ...css }); fxs[i].appendChild(el); return el; }; interface Fx { pieces: { el: HTMLElement; dx: number; dy: number; rot: number }[]; ghosts: { el: HTMLElement; dx: number; dy: number }[]; plates: { el: HTMLElement; dx: number; dy: number }[]; stickers: { el: HTMLElement; rot: number; dx: number; dy: number }[]; strips: HTMLElement[]; debris: HTMLElement[]; } const live = new Map<number, Fx>(); const buildFx = (i: number) => { const p = plan[i]; const r = p.rng.fork("fx"); const fx: Fx = { pieces: [], ghosts: [], plates: [], stickers: [], strips: [], debris: [] }; // CMY plates start off register and close onto the key text. plates().forEach((color) => fx.plates.push({ el: layer(i, { color, mixBlendMode: "var(--mp-blend)", opacity: "0.9" }), dx: r.range(-0.12, 0.12), dy: r.range(-0.08, 0.08) })); if (p.kind === "shatter") { const cuts = [0, ...Array.from({ length: p.pieces - 1 }, () => r.range(8, 92)).sort((a, b) => a - b), 100]; for (let k = 0; k < p.pieces; k++) { const [x0, x1] = [cuts[k], cuts[k + 1]]; const j = () => r.range(-6, 6); const clip = `polygon(${x0 + j()}% -10%, ${x1 + j()}% -10%, ${x1 + j()}% ${50 + j()}%, ${x1 + j()}% 110%, ${x0 + j()}% 110%, ${x0 + j()}% ${50 + j()}%)`; fx.pieces.push({ el: layer(i, { color: r.pick([...inks(), "currentColor"]), clipPath: clip }), dx: r.range(-0.7, 0.7), dy: r.range(-0.6, 0.6), rot: r.range(-28, 28) }); } } else if (p.kind === "ghost") { for (let k = 0; k < p.ghosts; k++) fx.ghosts.push({ el: layer(i, { color: r.pick(inks()), mixBlendMode: "var(--mp-blend)" }), dx: r.range(0.2, 0.45) * r.pick([-1, 1]), dy: r.range(-0.22, 0.22) }); } else { for (let k = 0; k < p.stickers; k++) { const el = layer(i, { left: "-0.2em", top: "-0.08em", padding: "0 0.2em", background: r.pick(pal().stickers), color: "#ffffff", fontWeight: "800", border: `0.08em solid ${pal().outline}`, borderRadius: "0.08em", boxShadow: `0.12em 0.12em 0 ${pal().outline}`, zIndex: String(2 + k), }); fx.stickers.push({ el, rot: r.range(-9, 9), dx: (k - (p.stickers - 1) / 2) * 0.12, dy: -k * 0.08 }); } } // Ink debris around the fresh word: halftone patches and slivers. for (let k = 0; k < p.debris; k++) { const halftone = r.chance(0.55); const size = r.range(0.35, 0.8); fx.debris.push( layer( i, halftone ? { width: `${size}em`, height: `${size}em`, left: `${r.range(-40, 110)}%`, top: `${r.range(-60, 90)}%`, color: r.pick(plates()), backgroundImage: "radial-gradient(currentColor 34%, transparent 36%)", backgroundSize: "0.18em 0.18em" } : { width: `${r.range(0.4, 1.3)}em`, height: "0.07em", left: `${r.range(-30, 100)}%`, top: `${r.range(-20, 110)}%`, background: r.pick([...plates(), "currentColor"]), transform: `rotate(${r.range(-35, 35)}deg)` }, "", ), ); } live.set(i, fx); return fx; }; const addStrips = (i: number, fx: Fx) => { if (fx.strips.length) return; for (let k = 0; k < 4; k++) fx.strips.push(layer(i, { clipPath: `inset(${k * 25}% 0 ${100 - (k + 1) * 25}% 0)`, color: "currentColor" })); }; const dropFx = (i: number) => { fxs[i].replaceChildren(); live.delete(i); }; const setWord = (i: number, visible: boolean, transform = "") => { keys[i].style.opacity = visible ? "1" : "0"; boxes[i].style.transform = transform; tearWords[i].style.opacity = visible ? "1" : "0"; }; /** Draws word i at `k` ticks after it started landing. */ const drawArrival = (i: number, k: number, tick: number) => { const p = plan[i]; if (k >= LIFE) { if (live.has(i)) dropFx(i); setWord(i, true); return; } const fx = live.get(i) ?? buildFx(i); const reveal = p.kind === "shatter" ? 6 : p.kind === "ghost" ? 3 : 1; const slipping = p.interlace && k >= reveal && k < reveal + 4; setWord(i, k >= reveal && !slipping); // Shattered pieces fly in and settle. const t = ease(k / 5); fx.pieces.forEach((pc) => { pc.el.style.opacity = k < reveal ? "1" : "0"; pc.el.style.transform = `translate(${pc.dx * (1 - t)}em, ${pc.dy * (1 - t)}em) rotate(${pc.rot * (1 - t)}deg)`; }); // Offset duplicates snap on. fx.ghosts.forEach((g) => { const s = 1 - Math.min(1, k / 3); g.el.style.opacity = k < reveal ? "0.95" : "0"; g.el.style.transform = `translate(${g.dx * s}em, ${g.dy * s}em)`; }); // CMY plates close onto the key text, then lift off. const close = 1 - Math.min(1, k / 6); fx.plates.forEach((pl) => { pl.el.style.opacity = k < 7 ? "0.9" : k < 8 ? "0.4" : "0"; pl.el.style.transform = `translate(${pl.dx * close}em, ${pl.dy * close}em)`; }); // Stickers jitter for a few ticks, then peel away and leave the plain word. fx.stickers.forEach((st, n) => { const peel = Math.max(0, (k - p.peelAt - n * 0.5) / 4); const jitter = k < p.peelAt ? p.rng.fork(`j${tick}:${n}`).range(-2.5, 2.5) : 0; st.el.style.opacity = peel >= 1 ? "0" : "1"; st.el.style.transform = `translate(${st.dx + peel * 0.5}em, ${st.dy - peel * 0.9}em) rotate(${st.rot + jitter + peel * 24}deg) scale(${1 - peel * 0.15})`; st.el.style.transformOrigin = "0% 100%"; }); // Interlaced words slip sideways in strips for a few frames. if (slipping) addStrips(i, fx); fx.strips.forEach((s, n) => { s.style.opacity = slipping ? "1" : "0"; s.style.transform = slipping ? `translateX(${p.rng.fork(`s${tick}:${n}`).range(-0.35, 0.35)}em)` : ""; }); fx.debris.forEach((d) => (d.style.opacity = String(Math.max(0, 1 - k / 10)))); }; /* ------------------------------------------------------------ band tears */ const drawTear = (tear: Tear | null, tick: number) => { if (!tear) { body.style.clipPath = ""; tearLayer.style.visibility = "hidden"; return; } const top = tear.top * 100; const bottom = (tear.top + tear.height) * 100; const jitter = createRandom(`${seed}:tear:${tick}`).range(-0.15, 0.15); // Cut the band out of the paragraph (even-odd hole) and show it, shifted, from the copy. body.style.clipPath = `polygon(evenodd, -30% -60%, 130% -60%, 130% 160%, -30% 160%, -30% -60%, -30% ${top}%, 130% ${top}%, 130% ${bottom}%, -30% ${bottom}%, -30% ${top}%)`; tearLayer.style.visibility = "visible"; tearLayer.style.clipPath = `inset(${top}% -30% ${100 - bottom}% -30%)`; tearLayer.style.transform = `translateX(${tear.shift + jitter}em)`; tearLayer.style.textShadow = tear.split ? `-0.1em 0 ${pal().cyan}, 0.1em 0 ${pal().magenta}, 0 0.06em ${pal().yellow}` : ""; }; function makeTear(r: Random, start: number, length: number): Tear { const height = r.range(0.12, 0.3); return { start, length, top: r.range(0, 1 - height), height, shift: r.range(0.45, 1.1) * r.pick([-1, 1]), split: r.chance(0.5) }; } /* ------------------------------------------------------------ timeline */ type Phase = "waiting" | "printing" | "idle" | "scatter"; let phase: Phase = autoplay ? "waiting" : "idle"; let printStart = 0; let scatterStart = 0; let replays = 0; let tick = 0; let elapsed = 0; let lastElapsed = 0; let elapsedTicks = 0; let last = 0; let raf = 0; let inView = false; let glitches: Glitch[] = []; let idleTear: Tear | null = null; let idleRng = rng.fork("idle"); let nextIdle = 0; let scatter: { dx: number; dy: number; rot: number; stray: boolean; sticker: boolean }[] = []; if (!autoplay) showAll(); const scheduleIdle = (from: number) => { nextIdle = from + idleRng.int(30, 84); }; const step = () => { if (phase === "printing") { const local = tick - printStart; plan.forEach((p, i) => { const k = local - p.at; if (k < 0) setWord(i, false); else if (k < LIFE + 1) drawArrival(i, k, tick); }); drawTear(tears.find((t) => local >= t.start && local < t.start + t.length) ?? null, tick); if (local >= printLength) { phase = "idle"; plan.forEach((_, i) => drawArrival(i, LIFE, tick)); drawTear(null, tick); scheduleIdle(tick); } } else if (phase === "idle" && settings.current.idleGlitch) { if (tick >= nextIdle) { const roll = idleRng.next(); if (roll < 0.08) { // A rare stuck band: holds ~0.4–0.6s, then snaps back. idleTear = makeTear(idleRng, tick, idleRng.int(10, 14)); } else { const count = roll < 0.65 ? 1 : idleRng.int(3, 6); for (let n = 0; n < count; n++) { const word = idleRng.int(0, words.length - 1); glitches.push({ word, type: count === 1 ? "flick" : idleRng.pick(["slip", "ghost"] as const), start: tick, length: count === 1 ? idleRng.int(1, 2) : idleRng.int(4, 6) }); } } scheduleIdle(tick); } drawTear(idleTear && tick < idleTear.start + idleTear.length ? idleTear : null, tick); if (idleTear && tick >= idleTear.start + idleTear.length) idleTear = null; glitches = glitches.filter((g) => { const k = tick - g.start; const fx = live.get(g.word) ?? buildFx(g.word); if (k >= g.length) { dropFx(g.word); setWord(g.word, true); return false; } const r = createRandom(`${seed}:g${g.start}:${g.word}:${k}`); if (g.type === "slip") { addStrips(g.word, fx); setWord(g.word, false); fx.strips.forEach((s) => (s.style.transform = `translateX(${r.range(-0.3, 0.3)}em)`)); } else { fx.plates.forEach((pl) => (pl.el.style.transform = `translate(${r.range(-0.14, 0.14)}em, ${r.range(-0.06, 0.06)}em)`)); fx.ghosts.forEach((gh) => (gh.el.style.opacity = g.type === "ghost" ? "0.9" : "0")); } fx.pieces.forEach((pc) => (pc.el.style.opacity = "0")); fx.stickers.forEach((st) => (st.el.style.opacity = "0")); fx.debris.forEach((d) => (d.style.opacity = "0")); return true; }); } else if (phase === "scatter") { const k = tick - scatterStart; const t = ease(k / 12); scatter.forEach((s, i) => { const fade = s.stray ? (k < 22 ? 1 : Math.max(0, 1 - (k - 22) / 6)) : Math.max(0, 1 - k / 9); keys[i].style.opacity = String(fade); boxes[i].style.transform = s.stray ? `translate(${s.dx * t * 0.4}em, ${s.dy * t * 0.4}em) rotate(${s.rot * t * 0.3}deg)` : `translate(${s.dx * t}em, ${s.dy * t}em) rotate(${s.rot * t}deg)`; tearWords[i].style.opacity = "0"; if (s.sticker && k === 1) { fxs[i].replaceChildren(); layer(i, { left: "-0.2em", padding: "0 0.2em", background: pal().stickers[i % pal().stickers.length], color: "#fff", fontWeight: "800", border: `0.08em solid ${pal().outline}`, boxShadow: `0.12em 0.12em 0 ${pal().outline}` }); } if (s.sticker && k === 5) fxs[i].replaceChildren(); }); if (k >= 30) { scatter.forEach((_, i) => { setWord(i, false); fxs[i].replaceChildren(); }); live.clear(); phase = "printing"; printStart = tick + 2; } } }; const loop = (now: number) => { raf = 0; if (!running()) return; elapsed += last ? Math.min(0.1, (now - last) / 1000) : 0; last = now; elapsedTicks += (elapsed - lastElapsed) * TPS * Math.max(0.1, settings.current.speed); lastElapsed = elapsed; const next = Math.floor(elapsedTicks); // Jumping several ticks (a slow frame) just draws the latest state. if (next !== tick) { tick = next; step(); } raf = requestAnimationFrame(loop); }; const running = () => inView && !document.hidden && (phase === "printing" || phase === "scatter" || (phase === "idle" && settings.current.idleGlitch)); const wake = () => { if (!raf && running()) { last = 0; raf = requestAnimationFrame(loop); } }; const io = new IntersectionObserver(([entry]) => { inView = entry?.isIntersecting ?? false; if (inView && phase === "waiting") { phase = "printing"; printStart = tick + 1; } wake(); }); io.observe(root); const onVisibility = () => wake(); document.addEventListener("visibilitychange", onVisibility); engine.current = { wake, replay: () => { const r = createRandom(`${seed}:scatter:${replays++}`); const strays = new Set(Array.from({ length: Math.min(words.length, r.int(3, 5)) }, () => r.int(0, words.length - 1))); scatter = words.map((_, i) => ({ dx: r.range(-3, 3), dy: r.range(-2.2, 2.2), rot: r.range(-40, 40), stray: strays.has(i), sticker: r.chance(0.25) })); live.forEach((_, i) => dropFx(i)); glitches = []; idleTear = null; idleRng = rng.fork("idle"); drawTear(null, tick); phase = "scatter"; scatterStart = tick; wake(); }, }; return () => { cancelAnimationFrame(raf); io.disconnect(); document.removeEventListener("visibilitychange", onVisibility); engine.current = null; showAll(); }; }, [words, seed, autoplay, stickers, reduceMotion]); const vars = { "--mp-blend": tone === "dark" ? "screen" : "multiply", ...style, } as CSSProperties; const wordsOf = (copy: boolean) => words.map((word, i) => ( <Fragment key={i}> <span {...(copy ? { "data-t": "" } : { "data-w": "" })} className="relative inline-block"> {copy ? word : <span data-k="">{word}</span>} {copy ? null : <span data-fx="" aria-hidden="true" className="pointer-events-none absolute inset-0 select-none" />} </span> {i < words.length - 1 ? " " : null} </Fragment> )); return ( <p ref={rootRef} className={className} style={vars} {...props}> <span className="relative block"> <span ref={bodyRef} className="block"> {wordsOf(false)} </span> {/* A copy of the key text, shown only inside a torn band. */} <span ref={tearRef} aria-hidden="true" className="pointer-events-none absolute inset-0 block select-none" style={{ visibility: "hidden" }}> {wordsOf(true)} </span> </span> </p> ); }
Usage
import { MisprintText, type MisprintTextHandle } from "@/components/ui/misprint-text/misprint-text";
const press = useRef<MisprintTextHandle>(null);
<MisprintText ref={press} text={paragraph} seed="keeper" className="text-2xl font-semibold" />
<button onClick={() => press.current?.replay()}>Reprint</button>API reference
| Prop | Type | Default | Description |
|---|---|---|---|
text* | string | – | The paragraph. Words are split on whitespace and stay real, selectable text. |
seed | string | number | "misprint" | The same seed always gives the same print, stickers and glitches. |
autoplay | boolean | true | Print word by word the first time the text scrolls into view. Off shows it printed until `replay()`. |
idleGlitch | boolean | true | Small flicks, bursts and the odd stuck band after printing. |
stickers | number | 0.125 | Share of words that land as a stack of sticker labels, 0–1. |
speed | number | 1 | Print speed multiplier. |
tone | "light" | "dark" | "light" | Plates multiply on light paper or screen on dark paper. Text color comes from the parent. |
palette | Partial<MisprintPalette> | – | Plate and sticker colors. |
ref | Ref<MisprintTextHandle> | – | Imperative handle with `replay()`. |
Also accepts every native <p> attribute, including ref and className.
- The print runs on one stepped rAF loop at 24 ticks per second, like a press rather than a smooth tween. Words land one to three ticks apart, with longer pauses after commas and sentence ends.
- Each word arrives one way: it shatters into three to eight flat-colored pieces cut with jagged clip-path polygons that fly in and settle, it gets one or two offset colored duplicates that snap on, or (a `stickers` share of words) it lands as a stack of one to three skewed labels with a thick outline, a hard shadow and the word knocked out in white, which jitter for a few ticks and peel away. Cyan, magenta and yellow plates (mix-blend-mode multiply, or screen on dark) close onto every word; some words slip sideways in four strips; halftone dots and ink slivers fade around fresh words.
- Band tears cut a horizontal band out of the paragraph with an even-odd clip-path hole and show it, shifted and sometimes split into plates, from an aria-hidden copy of the text, so nothing is drawn twice.
- Everything is seeded (`seed`): the same seed gives the same arrivals, stickers, tears and idle glitches. replay() scatters the words with a few flashing stickers, lets most dissolve while a handful of strays linger, then prints again from the start.
Accessibility
- The words are the real text: selectable, copyable and read once by screen readers. Every effect (plates, pieces, stickers, debris and the torn-band copy) is an aria-hidden layer.
- Under reduced motion the paragraph shows fully printed with no glitches, and replay() does nothing. Without JavaScript the text is simply visible.
- The print starts when the paragraph first scrolls into view, pauses offscreen and in hidden tabs, and idle glitches can be turned off with idleGlitch={false}.