"use client";
import * as React from "react";
export type FallingTextTrigger = "click" | "hover" | "view";
export type FallingTextSplit = "words" | "letters";
export interface FallingTextHandle {
/** Detach the pieces and let them fall. */
drop: () => void;
/** Return every piece to its place in the sentence. */
reset: () => void;
}
export interface FallingTextProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
/** The sentence. Exposed once to assistive technology. */
text?: string;
/** Pieces that fall: whole words or single letters. */
split?: FallingTextSplit;
/** What makes the sentence fall. The built-in button always works too. */
trigger?: FallingTextTrigger;
/** Gravity multiplier (1 ≈ 2400 px/s²). */
gravity?: number;
/** Restitution: 0 = dead stop, 0.8 = very bouncy. */
bounce?: number;
/** Let fallen pieces be dragged and thrown with the pointer. */
draggable?: boolean;
/** Draw the last word in the accent colour. */
accentLast?: boolean;
/** Accent colour (any CSS colour). */
accent?: string;
/** Height of the physics box in px (its floor). */
height?: number;
/** Show the Drop / Reset button. */
showControl?: boolean;
/** Changing this value resets the sentence. */
resetKey?: string | number;
/** Element used for the visually hidden sentence. */
as?: "p" | "h2" | "h3" | "span";
/** Imperative handle with `drop()` and `reset()`. */
ref?: React.Ref<FallingTextHandle>;
onFallChange?: (fallen: boolean) => void;
}
/* SSR-safe: renders "no preference" on the server and the first client pass, so markup always hydrates. */
function subscribeReducedMotion(onChange: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/* --------------------------------- physics -------------------------------- */
/** A capsule: a segment of half-length `hw` along the body's angle, thickened by radius `r`. */
interface Body {
x: number;
y: number;
vx: number;
vy: number;
angle: number;
spin: number;
hw: number;
r: number;
invMass: number;
invInertia: number;
/** Resting centre in the sentence, for writing relative transforms. */
x0: number;
y0: number;
}
interface Params {
gravity: number;
bounce: number;
}
interface Grab {
index: number;
pointerId: number;
/** Pointer target for the body's centre, in box coordinates. */
tx: number;
ty: number;
dx: number;
dy: number;
}
const BASE_GRAVITY = 2400; // px/s²
const STEP = 1 / 120;
const MAX_SPEED = 3200;
const FRICTION = 0.4;
const SLOP = 0.5;
const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));
function endpoints(b: Body) {
const ux = Math.cos(b.angle) * b.hw;
const uy = Math.sin(b.angle) * b.hw;
return [b.x - ux, b.y - uy, b.x + ux, b.y + uy] as const;
}
/** Closest points between segments p1–q1 and p2–q2 (Ericson, Real-Time Collision Detection 5.1.9). */
function closestSegmentPoints(
p1x: number, p1y: number, q1x: number, q1y: number,
p2x: number, p2y: number, q2x: number, q2y: number,
) {
const d1x = q1x - p1x, d1y = q1y - p1y, d2x = q2x - p2x, d2y = q2y - p2y;
const rx = p1x - p2x, ry = p1y - p2y;
const a = d1x * d1x + d1y * d1y, e = d2x * d2x + d2y * d2y, f = d2x * rx + d2y * ry;
let s = 0;
let t = 0;
if (a <= 1e-9 && e <= 1e-9) {
s = t = 0;
} else if (a <= 1e-9) {
t = clamp(f / e, 0, 1);
} else {
const c = d1x * rx + d1y * ry;
if (e <= 1e-9) {
s = clamp(-c / a, 0, 1);
} else {
const b = d1x * d2x + d1y * d2y;
const denom = a * e - b * b;
s = denom > 1e-9 ? clamp((b * f - c * e) / denom, 0, 1) : 0;
t = (b * s + f) / e;
if (t < 0) {
t = 0;
s = clamp(-c / a, 0, 1);
} else if (t > 1) {
t = 1;
s = clamp((b - c) / a, 0, 1);
}
}
}
return [p1x + d1x * s, p1y + d1y * s, p2x + d2x * t, p2y + d2y * t] as const;
}
/** Impulse response at contact point (cx, cy) with normal n pointing from A to B. B = null is the static box. */
function resolve(A: Body, B: Body | null, nx: number, ny: number, cx: number, cy: number, depth: number, bounce: number) {
const rax = cx - A.x, ray = cy - A.y;
const rbx = B ? cx - B.x : 0, rby = B ? cy - B.y : 0;
// Relative velocity of B with respect to A at the contact.
const vax = A.vx - A.spin * ray, vay = A.vy + A.spin * rax;
const vbx = B ? B.vx - B.spin * rby : 0, vby = B ? B.vy + B.spin * rbx : 0;
const rvx = vbx - vax, rvy = vby - vay;
const vn = rvx * nx + rvy * ny;
const imB = B ? B.invMass : 0, iiB = B ? B.invInertia : 0;
if (vn < 0) {
const raN = rax * ny - ray * nx;
const rbN = rbx * ny - rby * nx;
const k = A.invMass + imB + raN * raN * A.invInertia + rbN * rbN * iiB;
// No restitution for slow contacts, so stacks come to rest instead of jittering.
const e = vn < -90 ? bounce : 0;
const j = (-(1 + e) * vn) / k;
applyImpulse(A, B, nx * j, ny * j, rax, ray, rbx, rby);
// Coulomb friction along the tangent.
let tx = rvx - vn * nx, ty = rvy - vn * ny;
const tl = Math.hypot(tx, ty);
if (tl > 1e-6) {
tx /= tl;
ty /= tl;
const raT = rax * ty - ray * tx;
const rbT = rbx * ty - rby * tx;
const kt = A.invMass + imB + raT * raT * A.invInertia + rbT * rbT * iiB;
const jt = clamp(-(rvx * tx + rvy * ty) / kt, -FRICTION * j, FRICTION * j);
applyImpulse(A, B, tx * jt, ty * jt, rax, ray, rbx, rby);
}
}
// Positional correction removes the remaining overlap, split by inverse mass.
const total = A.invMass + imB;
const correction = Math.max(depth - SLOP, 0) * 0.8 / (total || 1);
A.x -= nx * correction * A.invMass;
A.y -= ny * correction * A.invMass;
if (B) {
B.x += nx * correction * B.invMass;
B.y += ny * correction * B.invMass;
}
}
function applyImpulse(A: Body, B: Body | null, jx: number, jy: number, rax: number, ray: number, rbx: number, rby: number) {
A.vx -= jx * A.invMass;
A.vy -= jy * A.invMass;
A.spin -= (rax * jy - ray * jx) * A.invInertia;
if (B) {
B.vx += jx * B.invMass;
B.vy += jy * B.invMass;
B.spin += (rbx * jy - rby * jx) * B.invInertia;
}
}
function collideWalls(b: Body, width: number, height: number, bounce: number) {
const [ax, ay, bx, by] = endpoints(b);
const ends = b.hw > 0.5 ? [ax, ay, bx, by] : [b.x, b.y];
for (let i = 0; i < ends.length; i += 2) {
const ex = ends[i], ey = ends[i + 1];
if (ey + b.r > height) resolve(b, null, 0, 1, ex, ey + b.r, ey + b.r - height, bounce);
if (ey - b.r < 0) resolve(b, null, 0, -1, ex, ey - b.r, b.r - ey, bounce);
if (ex - b.r < 0) resolve(b, null, -1, 0, ex - b.r, ey, b.r - ex, bounce);
if (ex + b.r > width) resolve(b, null, 1, 0, ex + b.r, ey, ex + b.r - width, bounce);
}
}
function collidePair(A: Body, B: Body, bounce: number) {
const reach = A.hw + A.r + B.hw + B.r;
if (Math.abs(A.x - B.x) > reach || Math.abs(A.y - B.y) > reach) return;
const [a1x, a1y, a2x, a2y] = endpoints(A);
const [b1x, b1y, b2x, b2y] = endpoints(B);
const [px, py, qx, qy] = closestSegmentPoints(a1x, a1y, a2x, a2y, b1x, b1y, b2x, b2y);
let dx = qx - px, dy = qy - py;
const dist = Math.hypot(dx, dy);
const radii = A.r + B.r;
if (dist >= radii) return;
if (dist < 1e-6) {
dx = 0;
dy = 1;
} else {
dx /= dist;
dy /= dist;
}
resolve(A, B, dx, dy, px + dx * A.r, py + dy * A.r, radii - dist, bounce);
}
function step(bodies: Body[], grab: Grab | null, params: Params, width: number, height: number) {
const g = BASE_GRAVITY * params.gravity;
for (let i = 0; i < bodies.length; i++) {
const b = bodies[i];
if (grab && grab.index === i) {
// A stiff spring towards the pointer; the resulting velocity is kept on release (a throw).
b.vx = clamp((grab.tx - b.x) * 22, -MAX_SPEED, MAX_SPEED);
b.vy = clamp((grab.ty - b.y) * 22, -MAX_SPEED, MAX_SPEED);
b.spin *= 0.8;
} else {
b.vy += g * STEP;
b.vx *= 0.9995;
b.vy *= 0.9995;
b.spin *= 0.995;
}
b.x += b.vx * STEP;
b.y += b.vy * STEP;
b.angle += b.spin * STEP;
}
for (let iteration = 0; iteration < 4; iteration++) {
for (let i = 0; i < bodies.length; i++) {
for (let j = i + 1; j < bodies.length; j++) collidePair(bodies[i], bodies[j], params.bounce);
collideWalls(bodies[i], width, height, params.bounce);
}
}
}
/* -------------------------------- component ------------------------------- */
/**
* A sentence whose words or letters detach and fall into a box with lightweight 2D physics:
* gravity, bounces off the floor and walls, rotation and capsule collisions between pieces.
* Fallen pieces can be dragged and thrown. The sentence stays readable to screen readers.
*/
export function FallingText({
text = "Gravity always wins in the end",
split = "words",
trigger = "click",
gravity = 1,
bounce = 0.35,
draggable = true,
accentLast = true,
accent = "#FF7448",
height = 300,
showControl = true,
resetKey,
as = "p",
ref,
onFallChange,
className = "",
style,
onClick,
onPointerEnter,
onPointerDown,
...props
}: FallingTextProps) {
const reduceMotion = usePrefersReducedMotion();
const rootRef = React.useRef<HTMLDivElement>(null);
const layerRef = React.useRef<HTMLDivElement>(null);
const paramsRef = React.useRef<Params>({ gravity, bounce });
const grabRef = React.useRef<Grab | null>(null);
const engineRef = React.useRef<{ wake: () => void; bodies: Body[] } | null>(null);
const [fallen, setFallen] = React.useState(false);
const [prevResetKey, setPrevResetKey] = React.useState(resetKey);
// "Adjust state when a prop changes": a new resetKey restores the sentence.
if (resetKey !== prevResetKey) {
setPrevResetKey(resetKey);
setFallen(false);
}
const active = fallen && !reduceMotion;
const setFall = (next: boolean) => {
if (next && reduceMotion) return;
setFallen(next);
onFallChange?.(next);
};
React.useImperativeHandle(ref, () => ({ drop: () => setFall(true), reset: () => setFall(false) }));
React.useEffect(() => {
paramsRef.current = { gravity: Math.max(0, gravity), bounce: clamp(bounce, 0, 0.95) };
engineRef.current?.wake();
}, [gravity, bounce]);
// Trigger "view": drop once the box is mostly visible.
React.useEffect(() => {
const root = rootRef.current;
if (!root || trigger !== "view" || fallen || reduceMotion) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.intersectionRatio >= 0.6)) {
setFallen(true);
onFallChange?.(true);
}
},
{ threshold: [0, 0.6] },
);
observer.observe(root);
return () => observer.disconnect();
}, [trigger, fallen, reduceMotion, onFallChange]);
// The simulation: runs while fallen, pauses offscreen, in hidden tabs and once everything rests.
React.useEffect(() => {
const root = rootRef.current;
const layer = layerRef.current;
if (!root || !layer) return;
const pieces = Array.from(layer.querySelectorAll<HTMLElement>("[data-fall-piece]"));
if (!active) {
// Ease every piece back into the sentence.
for (const el of pieces) {
if (!el.style.transform) continue;
el.style.transition = "transform 650ms cubic-bezier(0.22, 1, 0.36, 1)";
el.style.transform = "";
}
return;
}
const box = root.getBoundingClientRect();
const originX = box.left + root.clientLeft;
const originY = box.top + root.clientTop;
let width = root.clientWidth;
let boxHeight = root.clientHeight;
const bodies: Body[] = pieces.map((el) => {
el.style.transition = "none";
el.style.transform = "";
const rect = el.getBoundingClientRect();
const w = Math.max(2, rect.width);
const h = Math.max(2, rect.height);
const r = Math.max(1, Math.min(h * 0.42, w / 2));
const mass = (w * h) / 400;
const x0 = rect.left - originX + w / 2;
const y0 = rect.top - originY + h / 2;
return {
x: x0,
y: y0,
// A small random nudge so the sentence breaks apart instead of falling as a block.
vx: (Math.random() - 0.5) * 120,
vy: -Math.random() * 80,
angle: 0,
spin: (Math.random() - 0.5) * 3,
hw: Math.max(0, w / 2 - r),
r,
invMass: 1 / mass,
invInertia: 12 / (mass * (w * w + h * h)),
x0,
y0,
};
});
let frame = 0;
let last = 0;
let acc = 0;
let resting = 0;
let inView = true;
const render = () => {
for (let i = 0; i < bodies.length; i++) {
const b = bodies[i];
pieces[i].style.transform = `translate(${b.x - b.x0}px, ${b.y - b.y0}px) rotate(${b.angle}rad)`;
}
};
const tick = (now: number) => {
frame = 0;
const elapsed = last ? Math.min(0.05, (now - last) / 1000) : STEP;
last = now;
acc += elapsed;
let steps = 0;
while (acc >= STEP && steps < 6) {
step(bodies, grabRef.current, paramsRef.current, width, boxHeight);
acc -= STEP;
steps++;
}
render();
// Sleep once nothing has moved noticeably for a while.
const moving = bodies.some((b) => Math.abs(b.vx) > 6 || Math.abs(b.vy) > 6 || Math.abs(b.spin) > 0.05);
resting = moving || grabRef.current ? 0 : resting + 1;
if (resting < 45) frame = requestAnimationFrame(tick);
};
const wake = () => {
resting = 0;
if (!frame && inView && !document.hidden) {
last = 0;
frame = requestAnimationFrame(tick);
}
};
const sleep = () => {
cancelAnimationFrame(frame);
frame = 0;
};
const sync = () => (inView && !document.hidden ? wake() : sleep());
const observer = new IntersectionObserver((entries) => {
inView = entries.some((e) => e.isIntersecting);
sync();
});
observer.observe(root);
const resize = new ResizeObserver(() => {
width = root.clientWidth;
boxHeight = root.clientHeight;
wake();
});
resize.observe(root);
document.addEventListener("visibilitychange", sync);
engineRef.current = { wake, bodies };
wake();
return () => {
sleep();
observer.disconnect();
resize.disconnect();
document.removeEventListener("visibilitychange", sync);
engineRef.current = null;
grabRef.current = null;
};
}, [active, text, split]);
/* ------------------------------ pointer drag ----------------------------- */
const toBox = (event: React.PointerEvent) => {
const root = rootRef.current;
if (!root) return { x: 0, y: 0 };
const rect = root.getBoundingClientRect();
return { x: event.clientX - rect.left - root.clientLeft, y: event.clientY - rect.top - root.clientTop };
};
const handleLayerPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
const engine = engineRef.current;
if (!active || !draggable || !engine) return;
const piece = (event.target as HTMLElement).closest<HTMLElement>("[data-fall-piece]");
if (!piece) return;
const index = Number(piece.dataset.fallPiece);
const body = engine.bodies[index];
if (!body) return;
event.preventDefault();
const p = toBox(event);
grabRef.current = { index, pointerId: event.pointerId, dx: body.x - p.x, dy: body.y - p.y, tx: body.x, ty: body.y };
event.currentTarget.setPointerCapture(event.pointerId);
engine.wake();
};
const handleLayerPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
const grab = grabRef.current;
if (!grab || grab.pointerId !== event.pointerId) return;
const p = toBox(event);
grab.tx = p.x + grab.dx;
grab.ty = p.y + grab.dy;
};
const release = (event: React.PointerEvent<HTMLDivElement>) => {
if (grabRef.current?.pointerId === event.pointerId) grabRef.current = null;
};
/* -------------------------------- triggers ------------------------------- */
const handleClick = (event: React.MouseEvent<HTMLDivElement>) => {
onClick?.(event);
if (trigger === "click" && !fallen) setFall(true);
};
const handlePointerEnter = (event: React.PointerEvent<HTMLDivElement>) => {
onPointerEnter?.(event);
if (trigger === "hover" && event.pointerType === "mouse" && !fallen) setFall(true);
};
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
onPointerDown?.(event);
// Touch has no hover: a tap drops the sentence instead.
if (trigger === "hover" && event.pointerType !== "mouse" && !fallen) setFall(true);
};
const words = text.split(/\s+/).filter(Boolean);
let pieceIndex = 0;
const Tag = as;
return (
<div
ref={rootRef}
className={`relative w-full overflow-hidden rounded-xl border border-zinc-900/10 text-zinc-900 [--fall-ink:color-mix(in_oklab,var(--fall-accent)_78%,#18181b)] dark:border-white/10 dark:text-zinc-100 dark:[--fall-ink:var(--fall-accent)] ${
trigger === "click" && !active ? "cursor-pointer" : ""
} ${className}`}
style={{ ["--fall-accent" as string]: accent, height, ...style }}
onClick={handleClick}
onPointerEnter={handlePointerEnter}
onPointerDown={handlePointerDown}
{...props}
>
<Tag className="sr-only">{text}</Tag>
{/* The physics layer is decorative: the sentence above is what assistive technology reads. */}
<div
ref={layerRef}
aria-hidden="true"
className={`relative select-none p-6 ${showControl ? "pt-14" : ""} ${active && draggable ? "touch-none" : ""}`}
onPointerDown={handleLayerPointerDown}
onPointerMove={handleLayerPointerMove}
onPointerUp={release}
onPointerCancel={release}
>
{words.map((word, w) => {
const accented = accentLast && w === words.length - 1;
const tone = accented ? "text-[var(--fall-ink)]" : "";
const pieceClass = `inline-block will-change-transform ${active && draggable ? "cursor-grab active:cursor-grabbing" : ""}`;
return (
<React.Fragment key={w}>
{w > 0 ? " " : null}
{split === "letters" ? (
<span className={`inline-block whitespace-nowrap ${tone}`}>
{Array.from(word).map((letter, l) => (
<span key={l} data-fall-piece={pieceIndex++} className={pieceClass}>
{letter}
</span>
))}
</span>
) : (
<span data-fall-piece={pieceIndex++} className={`${pieceClass} ${tone}`}>
{word}
</span>
)}
</React.Fragment>
);
})}
</div>
{/* With reduced motion nothing falls, so the control is not offered. */}
{showControl && !reduceMotion ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
setFall(!active);
}}
aria-label={active ? "Reset the sentence" : "Drop the words"}
className="absolute right-3 top-3 inline-flex h-8 items-center gap-1.5 rounded-full border border-zinc-900/10 bg-white/80 px-3 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-600 outline-hidden backdrop-blur transition-colors hover:text-zinc-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--fall-accent)] dark:border-white/10 dark:bg-zinc-950/70 dark:text-zinc-400 dark:hover:text-white"
>
<span aria-hidden="true">{active ? "↺" : "↓"}</span>
{active ? "Reset" : "Drop"}
</button>
) : null}
</div>
);
}