"use client";
import * as React from "react";
import { motion, useMotionValue, useSpring, useTransform } from "motion/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);
}
type Tag = "h1" | "h2" | "h3" | "p" | "div" | "span";
export interface ExtrudedSpring {
stiffness: number;
damping: number;
mass: number;
}
export interface ExtrudedTextProps extends Omit<React.HTMLAttributes<HTMLElement>, "children"> {
/** The display text. */
children: string;
/** Element to render. */
as?: Tag;
/** Number of stacked layers behind the face (capped at 24). */
depth?: number;
/** Distance between layers in px. Total depth = depth × spacing. */
spacing?: number;
/** Side colour nearest the face (any CSS colour). */
sideColor?: string;
/** Side colour at the back; layers blend from sideColor to this. */
sideColorEnd?: string;
/** Maximum rotation toward the pointer, in degrees. */
maxTilt?: number;
/** Spring for the rotation. */
spring?: ExtrudedSpring;
/** Gentle idle sway while the pointer is away. */
sway?: boolean;
/** CSS perspective in px. */
perspective?: number;
}
const MAX_LAYERS = 24;
/** Resting angle: looking slightly up and left, so the extrusion shows below and to the right. */
const REST = { x: 14, y: -18 };
/**
* 3D extruded display text built from stacked copies of the text at increasing translateZ depths
* inside a preserve-3d group. The group turns toward the pointer on a spring. Each layer blends from
* the side colour to the end colour and darkens with depth; a light term driven by the current angle
* (a CSS variable fed by a motion value) brightens or shades the sides as the text turns, without
* re-rendering. Only the front face is exposed to assistive tech.
*/
export function ExtrudedText({
children,
as = "div",
depth = 14,
spacing = 1.5,
sideColor = "#FF7448",
sideColorEnd = "#3B1206",
maxTilt = 18,
spring = { stiffness: 120, damping: 14, mass: 0.8 },
sway = true,
perspective = 900,
className = "",
style,
onPointerEnter,
onPointerMove,
onPointerLeave,
...props
}: ExtrudedTextProps) {
const reduceMotion = usePrefersReducedMotion();
const documentHidden = React.useSyncExternalStore(subscribeVisibility, () => document.hidden, () => false);
const rootRef = React.useRef<HTMLElement | null>(null);
const [hovering, setHovering] = React.useState(false);
const [inView, setInView] = React.useState(true);
const rxTarget = useMotionValue(REST.x);
const ryTarget = useMotionValue(REST.y);
const rotateX = useSpring(rxTarget, spring);
const rotateY = useSpring(ryTarget, spring);
// 0 = sides facing the light (top-left), 1 = sides in shadow.
const shadow = useTransform(() => {
const v = 0.5 + (rotateX.get() - rotateY.get()) / (4 * Math.max(maxTilt, 1));
return Math.round(Math.min(1, Math.max(0, v)) * 1000) / 1000;
});
const layers = Math.min(MAX_LAYERS, Math.max(1, Math.round(depth)));
const swaying = sway && !reduceMotion && !hovering && inView && !documentHidden;
const setRoot = (node: HTMLElement | null) => {
rootRef.current = node;
};
React.useEffect(() => {
const root = rootRef.current;
if (!root) return;
const io = new IntersectionObserver(([entry]) => setInView(entry.isIntersecting));
io.observe(root);
return () => io.disconnect();
}, []);
React.useEffect(() => {
if (reduceMotion) {
rxTarget.jump(REST.x);
ryTarget.jump(REST.y);
rotateX.jump(REST.x);
rotateY.jump(REST.y);
}
}, [reduceMotion, rxTarget, ryTarget, rotateX, rotateY]);
// Idle sway: a slow figure-of-eight around the resting angle.
React.useEffect(() => {
if (!swaying) return;
let frame = 0;
const start = performance.now();
const tick = (now: number) => {
const t = (now - start) / 1000;
rxTarget.set(REST.x + Math.sin(t * 0.7) * 4);
ryTarget.set(REST.y + Math.sin(t * 0.45) * 7);
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [swaying, rxTarget, ryTarget]);
const track = (event: React.PointerEvent<HTMLElement>) => {
if (reduceMotion || event.pointerType === "touch") return;
const rect = event.currentTarget.getBoundingClientRect();
const px = (event.clientX - rect.left) / rect.width - 0.5;
const py = (event.clientY - rect.top) / rect.height - 0.5;
// Face turns to look at the pointer: right → rotateY+, up → rotateX+.
ryTarget.set(px * 2 * maxTilt);
rxTarget.set(-py * 2 * maxTilt);
};
const Component = as as React.ElementType;
return (
<Component
ref={setRoot}
{...props}
className={`relative inline-block p-[0.12em] ${className}`}
style={{ perspective: `${perspective}px`, ...style }}
onPointerEnter={(event: React.PointerEvent<HTMLElement>) => {
onPointerEnter?.(event);
if (event.pointerType !== "touch" && !reduceMotion) setHovering(true);
track(event);
}}
onPointerMove={(event: React.PointerEvent<HTMLElement>) => {
onPointerMove?.(event);
track(event);
}}
onPointerLeave={(event: React.PointerEvent<HTMLElement>) => {
onPointerLeave?.(event);
setHovering(false);
if (!reduceMotion) {
rxTarget.set(REST.x);
ryTarget.set(REST.y);
}
}}
>
<motion.span
className="relative block [transform-style:preserve-3d] will-change-transform"
style={{ rotateX, rotateY, ["--xt-shadow" as string]: shadow }}
>
{Array.from({ length: layers }, (_, index) => {
const i = index + 1;
const t = layers > 1 ? (i - 1) / (layers - 1) : 0;
const mix = `color-mix(in oklab, ${sideColor}, ${sideColorEnd} ${Math.round(t * 100)}%)`;
// Deeper layers darken (occlusion); the angle-driven --xt-shadow adds up to 30% more.
const dark = `clamp(0%, calc(${Math.round(4 + t * 26)}% + var(--xt-shadow, 0.5) * 30%), 90%)`;
// The layer right behind the face is lifted slightly to read as a bevel highlight.
const color = i === 1 ? `color-mix(in oklab, ${sideColor}, white 22%)` : `color-mix(in oklab, ${mix}, black ${dark})`;
return (
<span
key={i}
aria-hidden="true"
className="pointer-events-none absolute inset-0 select-none"
style={{ transform: `translateZ(${Math.round(-i * spacing * 100) / 100}px)`, color }}
>
{children}
</span>
);
})}
<span className="relative block">{children}</span>
</motion.span>
</Component>
);
}