"use client";
import { useEffect, useRef, type PointerEvent } from "react";
import { useAnimationFrame, useInView, useReducedMotion } from "motion/react";
type ShadowCastProps = {
text: string;
className?: string;
/** Shadow colour. Any CSS colour. */
shadow?: string;
/** Colour of the bulb and its pool of light. */
light?: string;
/** Longest shadow, in pixels. */
reach?: number;
};
type Point = { x: number; y: number };
const STEPS = 24;
// Letters are boxes of their own, so each word needs a nowrap box or a line can break mid-word.
function Words({ text }: { text: string }) {
return text.split(" ").map((word, w) => (
{w > 0 && " "}
{[...word].map((ch, i) => (
{ch}
))}
));
}
/* ShadowCast: a bare bulb hangs over the letters and each letter throws its own long shadow away from it. The bulb drifts alone or follows the pointer. */
export function ShadowCast({ text, className = "", shadow = "#05060c", light = "#ffd27a", reach = 150 }: ShadowCastProps) {
const reduce = useReducedMotion();
const box = useRef(null);
const bulb = useRef(null);
const layer = useRef(null);
const letters = useRef([]);
const inView = useInView(box);
const pointer = useRef(null);
const bulbAt = useRef(null);
const centres = useRef([]);
const stale = useRef(true);
useEffect(() => {
const el = box.current;
if (!el) return;
const measure = () => {
const home = el.getBoundingClientRect();
letters.current = Array.from(layer.current?.querySelectorAll("[data-letter]") ?? []);
centres.current = letters.current.map((s) => {
const r = s.getBoundingClientRect();
return { x: r.left - home.left + r.width / 2, y: r.top - home.top + r.height / 2 };
});
stale.current = true;
};
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, [text]);
useAnimationFrame((time) => {
const el = box.current;
if (!el || !inView || !centres.current.length) return;
const w = el.clientWidth;
const h = el.clientHeight;
const swing = reduce ? -0.6 : Math.sin(time / 2600);
const target = pointer.current ?? { x: w / 2 + w * 0.46 * swing, y: h * 0.14 };
const at = bulbAt.current ?? target;
const x = at.x + (target.x - at.x) * 0.1;
const y = at.y + (target.y - at.y) * 0.1;
if (!stale.current && Math.hypot(x - at.x, y - at.y) < 0.2) return;
stale.current = false;
bulbAt.current = { x, y };
if (bulb.current) bulb.current.style.transform = `translate(${x}px, ${y}px)`;
centres.current.forEach((c, i) => {
const letter = letters.current[i];
const dx = c.x - x;
const dy = c.y - y;
const far = Math.hypot(dx, dy) || 1;
const length = Math.min(reach, far * 0.6);
const layers: string[] = [];
for (let k = 1; k <= STEPS; k++) {
const t = k / STEPS;
const strength = Math.round(85 * (1 - t) ** 0.8);
const sx = ((dx / far) * length * t).toFixed(1);
const sy = ((dy / far) * length * t).toFixed(1);
layers.push(`${sx}px ${sy}px ${(t * 2).toFixed(1)}px color-mix(in srgb, ${shadow} ${strength}%, transparent)`);
}
letter.style.textShadow = layers.join(",");
});
});
const move = (e: PointerEvent) => {
const rect = e.currentTarget.getBoundingClientRect();
pointer.current = { x: e.clientX - rect.left, y: e.clientY - rect.top };
};
return (
(pointer.current = null)}
className={`relative inline-block overflow-hidden p-[0.7em] ${className}`}
>
{text}
{/* Two copies of the text: shadows under, clean letters over, so no shadow lands on a neighbouring letter. */}
);
}