Crumble to Sand
Installation
npx shadcn@latest add https://uibrik.com/r/crumble-to-sand.jsonAdd the cn helper
Imports
cnfrom@/lib/utils, whichshadcn initcreates. Add it by hand without shadcn.Copy the source into your project
components/uibrik/crumble-to-sand/crumble-to-sand.tsx"use client"; import { cn } from "@/lib/utils"; import { useEffect, useRef, useState } from "react"; import { SIZE, TEXT, pick, type EffectProps } from "../_text-effects/shared"; // 21 — The word breaks into grains that fall to the floor of the card and // pile up, sliding off any ledge steeper than one grain, then fly back up // into the next word. For the grains to read as the real text, the word is // rasterised on a 2px grid with each cell's ink coverage measured at 4×, so // edge grains are partly transparent like anti-aliased type, and the canvas // baseline is placed from the font's own metrics exactly where the browser // puts the DOM text. That makes the handoff between the two invisible. const SAND_WORDS = ["Sand pile", "Fall apart", "Build again"]; // The stage is 300 × 112 with the text 20px from its top at 20px type, and // scales with the type; the canvas reads its real size when it runs. const SAND_W_EM = 15; const SAND_H_EM = 5.6; const SAND_TEXT_TOP_EM = 1; const SAND_CELL = 2; const SAND_SUPERSAMPLE = 4; const SAND_HANDOFF = 200; // ms for live text to fade over or off the grains type Grain = { x: number; y: number; ink: number; // coverage of the source cell, 0–1 vy: number; delay: number; state: "wait" | "fall" | "rest"; x0: number; y0: number; a0: number; tx: number; ty: number; ta: number; riseDelay: number; alpha: number; }; type Cell = { x: number; y: number; ink: number }; type TextBox = { font: string; baseline: number }; // Where the browser draws the text: CSS centres the font's ascent + descent // inside the line box, so the baseline sits that far below the box's top. function measureTextBox(el: HTMLElement): TextBox { const style = getComputedStyle(el); const font = `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`; const ctx = document.createElement("canvas").getContext("2d"); if (!ctx) return { font, baseline: el.offsetTop + el.offsetHeight * 0.75 }; ctx.font = font; const m = ctx.measureText("Hg"); const ascent = m.fontBoundingBoxAscent; const descent = m.fontBoundingBoxDescent; return { font, baseline: el.offsetTop + (el.offsetHeight - (ascent + descent)) / 2 + ascent }; } function sampleCells(text: string, box: TextBox, SAND_W: number, SAND_H: number): Cell[] { const S = SAND_SUPERSAMPLE; const canvas = document.createElement("canvas"); canvas.width = SAND_W * S; canvas.height = SAND_H * S; const ctx = canvas.getContext("2d", { willReadFrequently: true }); if (!ctx) return []; ctx.scale(S, S); ctx.font = box.font; ctx.textAlign = "center"; ctx.textBaseline = "alphabetic"; ctx.fillText(text, SAND_W / 2, box.baseline); const { data } = ctx.getImageData(0, 0, canvas.width, canvas.height); const block = SAND_CELL * S; const out: Cell[] = []; for (let y = 0; y + SAND_CELL <= SAND_H; y += SAND_CELL) { for (let x = 0; x + SAND_CELL <= SAND_W; x += SAND_CELL) { let sum = 0; for (let dy = 0; dy < block; dy++) { const row = (y * S + dy) * canvas.width; for (let dx = 0; dx < block; dx++) sum += data[(row + x * S + dx) * 4 + 3]; } const ink = sum / (block * block * 255); if (ink > 0.04) out.push({ x, y, ink }); } } return out; } const byPosition = (a: { x: number; y: number }, b: { x: number; y: number }) => a.x - b.x || a.y - b.y; const easeInOutCubic = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2); export function CrumbleToSand({ play, words = SAND_WORDS, gravity: SAND_GRAVITY = 1600, riseDuration: SAND_RISE = 0.6, arc: SAND_ARC = 10, }: EffectProps & { words?: string[]; /** How fast grains fall, px/s². */ gravity?: number; /** Seconds for grains to fly up into the next word. */ riseDuration?: number; /** Px the grains lift on their way to the next word. */ arc?: number; }) { const stageRef = useRef<HTMLDivElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null); const textRef = useRef<HTMLParagraphElement>(null); const [shown, setShown] = useState(0); const [textOn, setTextOn] = useState(true); const [canvasOn, setCanvasOn] = useState(false); useEffect(() => { const canvas = canvasRef.current; const textEl = textRef.current; const ctx = canvas?.getContext("2d"); if (!play || !canvas || !textEl || !ctx) return; const SAND_W = Math.round(stageRef.current?.offsetWidth ?? 300); const SAND_H = Math.round(stageRef.current?.offsetHeight ?? 112); const box = measureTextBox(textEl); const dpr = Math.min(window.devicePixelRatio || 1, 2); canvas.width = SAND_W * dpr; canvas.height = SAND_H * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); const grains: Grain[] = sampleCells(pick(words, play - 1), box, SAND_W, SAND_H).map((c) => ({ x: c.x, y: c.y, ink: c.ink, vy: 0, // Held still while the live text fades off the grains, then a wave. delay: SAND_HANDOFF / 1000 + (c.x / SAND_W) * 0.35 + Math.random() * 0.12, state: "wait", x0: 0, y0: 0, a0: 0, tx: 0, ty: 0, ta: 0, riseDelay: 0, alpha: c.ink, })); const targets = sampleCells(pick(words, play), box, SAND_W, SAND_H).sort(byPosition); const cols = Math.floor(SAND_W / SAND_CELL); const heights = new Array<number>(cols).fill(0); const floor = SAND_H - SAND_CELL; let start = 0; let last = 0; let riseAt = 0; let frame = 0; let handoff: ReturnType<typeof setTimeout> | undefined; const land = (g: Grain) => { let col = Math.min(cols - 1, Math.max(0, Math.round(g.x / SAND_CELL))); // Slide off any ledge steeper than one grain so the pile spreads. for (let step = 0; step < 8; step++) { const left = col > 0 ? heights[col - 1] : Infinity; const right = col < cols - 1 ? heights[col + 1] : Infinity; if (Math.min(left, right) >= heights[col] - 1) break; col = left < right || (left === right && Math.random() < 0.5) ? col - 1 : col + 1; } g.x = col * SAND_CELL; g.y = floor - heights[col] * SAND_CELL; heights[col]++; g.state = "rest"; }; const beginRise = () => { grains.sort(byPosition); while (grains.length < targets.length && grains.length) { const from = grains[Math.floor(Math.random() * grains.length)]; grains.push({ ...from }); } grains.forEach((g, i) => { const target = targets[i]; const spare = targets[Math.floor(Math.random() * targets.length)]; g.x0 = g.x; g.y0 = g.y; g.a0 = g.alpha; g.tx = (target ?? spare)?.x ?? g.x; g.ty = (target ?? spare)?.y ?? g.y; // Spare grains land on an existing cell and fade out on the way. g.ta = target ? target.ink : 0; // Fixed by position, not random, so neighbours move as one front. g.riseDelay = (g.tx / SAND_W) * 0.3 + (g.ty / SAND_H) * 0.05; }); }; const draw = () => { ctx.clearRect(0, 0, SAND_W, SAND_H); ctx.fillStyle = getComputedStyle(canvas).color; for (const g of grains) { if (g.alpha <= 0.01) continue; ctx.globalAlpha = g.alpha; // Snap to device pixels so moving grains never smear across two. ctx.fillRect(Math.round(g.x * dpr) / dpr, Math.round(g.y * dpr) / dpr, SAND_CELL, SAND_CELL); } ctx.globalAlpha = 1; }; const tick = (now: number) => { const t = (now - start) / 1000; const dt = Math.min((now - last) / 1000, 0.05); last = now; if (!riseAt) { let settled = true; for (const g of grains) { if (g.state === "wait") { if (t < g.delay) { settled = false; continue; } g.state = "fall"; } if (g.state === "fall") { settled = false; g.vy += SAND_GRAVITY * dt; g.y += g.vy * dt; // Grains firm up to solid sand as they fall. g.alpha = Math.min(1, g.alpha + dt * 4); const col = Math.min(cols - 1, Math.max(0, Math.round(g.x / SAND_CELL))); if (g.y >= floor - heights[col] * SAND_CELL) land(g); } } if (settled || t > 2.4) { riseAt = t + 0.25; beginRise(); } } else if (t >= riseAt) { const rt = t - riseAt; let done = true; for (const g of grains) { const local = Math.min(1, Math.max(0, (rt - g.riseDelay) / SAND_RISE)); if (local < 1) done = false; const e = easeInOutCubic(local); g.x = g.x0 + (g.tx - g.x0) * e; g.y = g.y0 + (g.ty - g.y0) * e - Math.sin(Math.PI * e) * SAND_ARC; g.alpha = g.a0 + (g.ta - g.a0) * e; } if (done) { // The finished grains stay on screen while the live text fades in // on top of them; only then is the canvas dropped. Clearing it in // this frame would leave one blank frame before React shows the text. draw(); setShown(play); setTextOn(true); handoff = setTimeout(() => { setCanvasOn(false); ctx.clearRect(0, 0, SAND_W, SAND_H); }, SAND_HANDOFF + 20); return; } } draw(); frame = requestAnimationFrame(tick); }; // The grains are drawn in place first, then the live text fades off // them, so the break-up starts from exactly what was on screen. frame = requestAnimationFrame((now) => { start = last = now; draw(); setCanvasOn(true); setTextOn(false); frame = requestAnimationFrame(tick); }); return () => { cancelAnimationFrame(frame); clearTimeout(handoff); }; // copy changes remount the effect (TextEffect keys on it), so only `play` drives this // eslint-disable-next-line react-hooks/exhaustive-deps }, [play]); return ( <div ref={stageRef} className={cn(SIZE, "relative")} style={{ width: `${SAND_W_EM}em`, height: `${SAND_H_EM}em` }}> <canvas ref={canvasRef} aria-hidden className={cn("absolute inset-0 size-full text-zinc-700 dark:text-zinc-200", !canvasOn && "invisible")} /> {/* Sits above the canvas so it can fade in over the finished grains. */} <p ref={textRef} className={cn(TEXT, "absolute inset-x-0 text-center transition-opacity ease-out", !textOn && "opacity-0")} style={{ top: `${SAND_TEXT_TOP_EM}em`, transitionDuration: `${SAND_HANDOFF}ms` }} > {pick(words, shown)} </p> </div> ); }
Adds the files to
components/uibrik/crumble-to-sand. With the @uibrik namespace set up: npx shadcn@latest add @uibrik/crumble-to-sand.Props
CrumbleToSand- words
- Type string[]
- ["Sand pile","Fall apart","Build again"]
- Cycled through, one per play.
- gravity
- Type number
- 1600
- How fast grains fall, px/s².
- riseDuration
- Type number
- 0.6
- Time for grains to fly into the next word.
- arc
- Type number
- 10
- Lift on the way to the next word.
- playrequired
- Type number
- –
- Each change plays the effect again; 0 shows its resting state.