# Spotlight Reveal Hero

> Build an interactive hero where a cursor-following spotlight reveals a hidden second image through a soft circular mask laid over a base image. Dark, premium, image-driven, with a serif accent wordmark and load-in blur/zoom animations. Use this skill whenever the user wants a "spotlight", "flashlight", "cursor reveal", "hover to reveal", "torch effect", or an interactive image hero where moving the mouse uncovers something underneath. This is the go-to for a tactile, exploratory hero built on images rather than video.

- Skill: `brcapitalusa/spotlight-reveal-hero` (Agent Skill)
- Install (CLI): `npx skillmds@latest add brcapitalusa/spotlight-reveal-hero`
- Raw SKILL.md: https://api.skillmd.com/api/skills/brcapitalusa/spotlight-reveal-hero/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: BRCapitalUSA (https://skillmd.com/u/brcapitalusa)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/brcapitalusa/spotlight-reveal-hero

---


# 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.

```tsx
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)
```css
@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-none` and 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.

