Text Effects
ShadowCast
A bare bulb hangs over the letters and each one throws its own long shadow away from it. The bulb drifts on its own, or follows the pointer.
Download
- React
- Tailwind CSS v4
- motion
components/ui/shadow-cast.tsx
"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) => (
<span key={w}>
{w > 0 && " "}
<span className="inline-block whitespace-nowrap">
{[...word].map((ch, i) => (
<span key={i} data-letter className="inline-block">
{ch}
</span>
))}
</span>
</span>
));
}
/* 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<HTMLSpanElement>(null);
const bulb = useRef<HTMLSpanElement>(null);
const layer = useRef<HTMLSpanElement>(null);
const letters = useRef<HTMLElement[]>([]);
const inView = useInView(box);
const pointer = useRef<Point | null>(null);
const bulbAt = useRef<Point | null>(null);
const centres = useRef<Point[]>([]);
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<HTMLElement>("[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<HTMLSpanElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
pointer.current = { x: e.clientX - rect.left, y: e.clientY - rect.top };
};
return (
<span
ref={box}
onPointerMove={move}
onPointerDown={move}
onPointerLeave={() => (pointer.current = null)}
className={`relative inline-block overflow-hidden p-[0.7em] ${className}`}
>
<span className="sr-only">{text}</span>
<span ref={bulb} aria-hidden className="pointer-events-none absolute top-0 left-0">
<span className="absolute -top-[260px] -left-[260px] size-[520px]" style={{ background: `radial-gradient(closest-side, ${light}3d, transparent)` }} />
<span className="absolute -top-[7px] -left-[7px] size-[14px] rounded-full bg-white" style={{ boxShadow: `0 0 10px 3px ${light}, 0 0 34px 10px ${light}99` }} />
</span>
{/* Two copies of the text: shadows under, clean letters over, so no shadow lands on a neighbouring letter. */}
<span aria-hidden className="relative block">
<span ref={layer} className="absolute inset-0 block text-transparent">
<Words text={text} />
</span>
<span className="relative block">
<Words text={text} />
</span>
</span>
</span>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/shadow-cast.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/shadow-cast-demo.tsx
import { ShadowCast } from "@/components/ui/shadow-cast"; export default function ShadowCastExample() { return ( <div className="@container h-full bg-[#d98a4b]"> <ShadowCast text="LATE SUN" shadow="#6b2233" light="#fff1c9" className="grid size-full place-content-center text-center text-[min(14cqw,92px)] font-black tracking-tight text-[#fff4e0]" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| text | string | — | Text to light. |
| shadow | string | "#05060c" | Shadow colour. |
| light | string | "#ffd27a" | Colour of the bulb and its pool of light. |
| reach | number | 150 | Longest shadow, in pixels. |
| className | string | "" | Type styles and size of the lit stage. |