"use client";
import * as React from "react";
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
/** SSR-safe: hydrates as "no preference", then follows the media query. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
function subscribeVisibility(callback: () => void) {
document.addEventListener("visibilitychange", callback);
return () => document.removeEventListener("visibilitychange", callback);
}
export type TextPrismShape = "triangle" | "square";
export type TextPrismAxis = "x" | "y";
export type TextPrismVariant = "accent" | "plain" | "solid";
export interface TextPrismProps extends Omit<React.ComponentPropsWithoutRef<"span">, "children"> {
/** Words placed on the prism faces, shown in order. */
words: string[];
/** Time each word stays in front, in ms (includes the turn). */
interval?: number;
/** Length of one turn in ms. */
duration?: number;
/** Triangular (3 faces) or square (4 faces) prism. */
shape?: TextPrismShape;
/** `x` tumbles vertically (next word rolls up from below); `y` spins sideways. */
axis?: TextPrismAxis;
/** CSS perspective in px — lower is more dramatic. */
perspective?: number;
/** `accent` colours the words, `plain` inherits the text colour, `solid` puts them on accent faces. */
variant?: TextPrismVariant;
/** Hold the current word while a mouse is over it. */
pauseOnHover?: boolean;
/** Stop after this many passes through the list, on the last word. 0 = loop forever. */
loops?: number;
/** Show a small pause / play button while the prism is looping. */
showPauseButton?: boolean;
/** Word colour, solid face colour and focus ring (any CSS colour). */
accent?: string;
/** Screen-reader text read once instead of the rotating words. Defaults to the words joined by commas. */
srLabel?: string;
}
const mod = (n: number, m: number) => ((n % m) + m) % m;
const OVERSHOOT = "cubic-bezier(0.34, 1.32, 0.5, 1)";
/**
* Words printed on the faces of a CSS 3D prism that tumbles to the next word. Faces sit at
* translateZ(r) around the axis, where r is derived from the measured line height (or the reserved
* width for the y axis), and the faces are refilled as they rotate out of view, so any number of
* words works with three or four faces. The visible prism is aria-hidden; screen readers get the
* full list once. Reduced motion swaps the rotation for a crossfade.
*/
export function TextPrism({
words,
interval = 2400,
duration = 700,
shape = "triangle",
axis = "x",
perspective = 600,
variant = "accent",
pauseOnHover = true,
loops = 0,
showPauseButton = true,
accent = "#FF7448",
srLabel,
className = "",
style,
onPointerEnter,
onPointerLeave,
...props
}: TextPrismProps) {
const reduceMotion = usePrefersReducedMotion();
const documentHidden = React.useSyncExternalStore(subscribeVisibility, () => document.hidden, () => false);
const stageRef = React.useRef<HTMLSpanElement>(null);
const [step, setStep] = React.useState(0);
const [hovered, setHovered] = React.useState(false);
const [userPaused, setUserPaused] = React.useState(false);
const [inView, setInView] = React.useState(true);
const count = words.length;
const faces = shape === "square" ? 4 : 3;
const angle = 360 / faces;
const finished = loops > 0 && step >= loops * count - 1;
const running = count > 1 && !finished && !userPaused && !(pauseOnHover && hovered) && inView && !documentHidden;
React.useEffect(() => {
if (!running) return;
const timer = window.setTimeout(() => setStep((s) => s + 1), Math.max(interval, duration + 100));
return () => window.clearTimeout(timer);
}, [running, step, interval, duration]);
// Pause offscreen; publish the measured size so the face radius matches the real line box.
React.useEffect(() => {
const stage = stageRef.current;
if (!stage) return;
const io = new IntersectionObserver(([entry]) => setInView(entry.isIntersecting));
io.observe(stage);
const ro = new ResizeObserver(() => {
stage.style.setProperty("--tp-h", `${Math.round(stage.offsetHeight * 100) / 100}px`);
stage.style.setProperty("--tp-w", `${Math.round(stage.offsetWidth * 100) / 100}px`);
});
ro.observe(stage);
return () => {
io.disconnect();
ro.disconnect();
};
}, []);
const index = count ? mod(step, count) : 0;
const solid = variant === "solid";
const pad = solid ? "px-[0.22em]" : "";
// Apothem of the regular prism: half the face size / tan(π / faces).
const k = Math.round((0.5 / Math.tan(Math.PI / faces)) * 10000) / 10000;
const radius = axis === "x" ? `calc(var(--tp-h, 1.2em) * ${k})` : `calc(var(--tp-w, 4em) * ${k})`;
const rotate = axis === "x" ? "rotateX" : "rotateY";
const sign = axis === "x" ? 1 : -1;
const turn = `${duration}ms ${OVERSHOOT}`;
const faceTone =
variant === "accent"
? "text-[var(--tp-accent)]"
: solid
? "rounded-[0.14em] bg-[var(--tp-accent)] text-zinc-950 shadow-[inset_0_0_0_1px_rgb(0_0_0/0.12),inset_0_1px_0_rgb(255_255_255/0.35)]"
: "";
const prism = (
<span
className="pointer-events-none absolute inset-0 [transform-style:preserve-3d]"
style={{
transform: `translateZ(calc(${radius} * -1)) ${rotate}(${sign * step * angle}deg)`,
transition: `transform ${turn}`,
}}
>
{Array.from({ length: faces }, (_, face) => {
// The latest step (up to the next one) that lands on this face decides its word.
const t = step + 1 - mod(step + 1 - face, faces);
const front = mod(step, faces) === face;
return (
<span
key={face}
className={`absolute inset-0 flex items-center whitespace-nowrap [backface-visibility:hidden] ${pad} ${faceTone}`}
style={{
transform: `${rotate}(${-sign * face * angle}deg) translateZ(${radius})`,
opacity: front ? 1 : solid ? 1 : 0.2,
filter: solid && !front ? "brightness(0.55)" : undefined,
transition: `opacity ${duration}ms ease, filter ${duration}ms ease`,
}}
>
{count ? words[mod(t, count)] : null}
</span>
);
})}
</span>
);
const crossfade = words.map((word, i) => (
<span
key={i}
className={`col-start-1 row-start-1 whitespace-nowrap transition-opacity duration-500 ${pad} ${faceTone}`}
style={{ opacity: i === index ? 1 : 0 }}
>
{word}
</span>
));
const canPause = showPauseButton && count > 1 && !finished;
return (
<span
{...props}
className={`inline-flex items-baseline gap-[0.18em] ${className}`}
style={{ ["--tp-accent" as string]: accent, ...style }}
onPointerEnter={(event) => {
onPointerEnter?.(event);
if (event.pointerType === "mouse") setHovered(true);
}}
onPointerLeave={(event) => {
onPointerLeave?.(event);
setHovered(false);
}}
>
<span className="sr-only">{srLabel ?? words.join(", ")}</span>
<span
ref={stageRef}
aria-hidden="true"
data-paused={!running || undefined}
className="relative inline-grid align-baseline"
style={{ perspective: `${perspective}px` }}
>
{/* Every word stacked invisibly in one cell reserves the width of the longest one. */}
{reduceMotion
? crossfade
: words.map((word, i) => (
<span key={i} className={`invisible col-start-1 row-start-1 whitespace-nowrap ${pad}`}>
{word}
</span>
))}
{!reduceMotion && prism}
</span>
{canPause && (
<button
type="button"
onClick={() => setUserPaused((p) => !p)}
aria-label={userPaused ? "Resume word rotation" : "Pause word rotation"}
className="relative inline-grid size-6 shrink-0 cursor-pointer place-items-center self-center rounded-full border border-current/15 text-current/60 outline-hidden transition-colors hover:border-current/40 hover:text-current focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--tp-accent)]"
>
<svg aria-hidden="true" viewBox="0 0 12 12" className="size-2.5" fill="currentColor">
{userPaused ? <path d="M3 1.8v8.4L10 6 3 1.8Z" /> : <path d="M2.5 1.5h2.4v9H2.5zM7.1 1.5h2.4v9H7.1z" />}
</svg>
</button>
)}
</span>
);
}