"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. */} ); }