Spotlight Reveal Hero
Two full-bleed images stacked; the top one is invisible except inside a glowing circle that trails the cursor. Feels like shining a light across a surface.
Before building
Read /mnt/skills/public/frontend-design/SKILL.md. Collect: BASE_IMAGE (what
you see by default), REVEAL_IMAGE (what the spotlight uncovers), BRAND,
HEADLINE (2 lines, one may be serif-italic), short corner paragraphs, and a
PRIMARY_CTA. Palette: dark, white text, one warm accent for the button
(e.g. #e8702a). Fonts: Inter body + Playfair Display / Instrument Serif
italic accent.
Stack
React 18 + TypeScript + Vite + Tailwind, lucide-react. Single App.tsx.
(A pure-HTML/canvas variant is possible if the user wants no build step.)
The core mechanic (get this exactly right)
Track the cursor with easing, draw a radial gradient to a hidden canvas, and use the canvas as a CSS mask on the reveal layer.
const SPOTLIGHT_R = 260;
function useSpotlight() {
const mouse = React.useRef({ x: -999, y: -999 });
const smooth = React.useRef({ x: -999, y: -999 });
const [pos, setPos] = React.useState({ x: -999, y: -999 });
React.useEffect(() => {
const move = (e: MouseEvent) => { mouse.current = { x: e.clientX, y: e.clientY }; };
window.addEventListener("mousemove", move);
let raf: number;
const loop = () => {
smooth.current.x += (mouse.current.x - smooth.current.x) * 0.1;
smooth.current.y += (mouse.current.y - smooth.current.y) * 0.1;
setPos({ x: smooth.current.x, y: smooth.current.y });
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => { window.removeEventListener("mousemove", move); cancelAnimationFrame(raf); };
}, []);
return pos;
}
function RevealLayer({ image, x, y }: { image: string; x: number; y: number }) {
const canvas = React.useRef<HTMLCanvasElement>(null);
const div = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
const c = canvas.current!, ctx = c.getContext("2d")!;
c.width = window.innerWidth; c.height = window.innerHeight;
ctx.clearRect(0, 0, c.width, c.height);
const g = ctx.createRadialGradient(x, y, 0, x, y, SPOTLIGHT_R);
[[0,1],[0.4,1],[0.6,0.75],[0.75,0.4],[0.88,0.12],[1,0]].forEach(([s,a]) =>
g.addColorStop(s as number, `rgba(255,255,255,${a})`));
ctx.beginPath(); ctx.arc(x, y, SPOTLIGHT_R, 0, Math.PI * 2); ctx.fillStyle = g; ctx.fill();
const url = c.toDataURL();
if (div.current) {
div.current.style.webkitMaskImage = `url(${url})`;
div.current.style.maskImage = `url(${url})`;
div.current.style.webkitMaskSize = "100% 100%";
div.current.style.maskSize = "100% 100%";
}
}, [x, y, image]);
return (<>
<canvas ref={canvas} className="absolute inset-0 pointer-events-none" style={{ display: "none" }} />
<div ref={div} className="absolute inset-0 bg-center bg-cover z-30 pointer-events-none"
style={{ backgroundImage: `url(${image})` }} />
</>);
}
Layer order: base image z-10, RevealLayer z-30, all text/nav z-50
(pointer-events-none so they never block the spotlight). Section is
relative h-screen overflow-hidden bg-black with style={{ height: "100dvh" }}.
Load-in animations (premium)
@keyframes heroReveal { 0%{opacity:0;transform:translateY(28px);filter:blur(12px)} 100%{opacity:1;transform:translateY(0);filter:blur(0)} }
@keyframes heroZoom { 0%{transform:scale(1.12)} 100%{transform:scale(1)} }
Base image gets a slow heroZoom (Ken-Burns). Heading lines get heroReveal
staggered (0.25s, 0.42s); corner paragraphs fade up (0.7s, 0.85s). All
with cubic-bezier(0.16,1,0.3,1), and disabled under prefers-reduced-motion.
Layout
Heading top-center (absolute top-[14%], two block spans, one serif-italic).
Bottom-left and bottom-right small paragraphs + the accent CTA button
(rounded-full, hover:scale-[1.03]). Fixed nav on top: serif wordmark left,
a bg-white/20 backdrop-blur-md border border-white/30 rounded-full pill of
links center (desktop), a solid pill CTA right.
Responsive
Spotlight is desktop-delightful; on touch, the reveal image can simply show at
reduced opacity (no cursor). Heading scales text-5xl → sm:text-7xl → md:text-8xl. Use 100dvh so mobile chrome doesn't clip. Hide the center nav
pill below md.
Quality checklist
- Moving the mouse smoothly reveals the second image inside a soft circle.
- Text layers are
pointer-events-noneand sit above the reveal. - Base image has the slow zoom; headings blur-rise on load.
- Section uses
100dvh; nothing clips on mobile. - Reduced-motion path disables animations.