Spotlight Reveal Hero with Hidden Colour Layer

A dark hero drawn as line art: outlined headline, ghost buttons and a strip of storyboard frames in thin grey strokes. Stacked exactly on top is the same hero in full colour, and a circular mask lets it show only inside a lens around the pointer. The lens follows on a spring, swells when it moves fast and carries a small tag on its rim. With no pointer it roams the page by itself. A button opens the lens until it covers everything, so the colour version is reachable by keyboard and touch. On phones the lens shrinks, the buttons stack and the strip shows three frames.

Code

import { useEffect, useRef, useState } from "react";
import { motion, useMotionTemplate, useMotionValue, useReducedMotion, useSpring } from "framer-motion";

/** Radius of the lens as a share of the hero width, with a floor and a ceiling in pixels. */
const LENS_SHARE = 0.19;
const LENS_MIN = 105;
const LENS_MAX = 215;
/** How much the lens grows while it is moving fast, in pixels. */
const SPEED_SWELL = 46;
/** Speed of the idle roam. 1 is a slow drift, 2 is restless. */
const ROAM_SPEED = 1;

const headline = ["Colour is", "hiding in", "every frame."];

// The storyboard strip. Each frame is drawn as line art in the dark layer and painted in the colour layer.
const frames = [
  { code: "SC 03", time: "00:12", sky: "#ffd166", sun: "#fff3c4", far: "#f4845f", near: "#7b2d5b", sunX: 62, sunY: 30 },
  { code: "SC 07", time: "00:41", sky: "#8ecae6", sun: "#ffffff", far: "#219ebc", near: "#023e58", sunX: 28, sunY: 24 },
  { code: "SC 11", time: "01:08", sky: "#ff8fab", sun: "#ffe5ec", far: "#c9184a", near: "#590d22", sunX: 50, sunY: 40 },
  { code: "SC 14", time: "01:37", sky: "#b8f2a0", sun: "#fdffb6", far: "#2a9d8f", near: "#1b4d3e", sunX: 74, sunY: 22 },
  { code: "SC 19", time: "02:05", sky: "#cdb4ff", sun: "#fff0f6", far: "#7b5cd6", near: "#2b1a66", sunX: 36, sunY: 34 },
];

const INK = "#16031c";

function Frame({ f, vivid, index }: { f: (typeof frames)[number]; vivid: boolean; index: number }) {
  const line = "rgba(255,255,255,0.26)";
  return (
    <li className={`min-w-0 flex-1 ${index > 2 ? "hidden sm:block" : ""}`}>
      <svg viewBox="0 0 100 62" className="block w-full rounded-md" style={{ boxShadow: vivid ? `0 0 0 2px ${INK}` : `0 0 0 1px ${line}` }} aria-hidden="true">
        <rect width="100" height="62" fill={vivid ? f.sky : "transparent"} />
        <circle cx={f.sunX} cy={f.sunY} r="9" fill={vivid ? f.sun : "none"} stroke={vivid ? "none" : line} strokeWidth="0.8" />
        <path d={`M0 46 Q ${18 + index * 4} ${28 + (index % 2) * 6} 42 42 T 100 ${34 + (index % 3) * 4} V62 H0Z`} fill={vivid ? f.far : "none"} stroke={vivid ? "none" : line} strokeWidth="0.8" />
        <path d={`M0 ${54 - (index % 2) * 4} Q 30 ${44 + (index % 3) * 3} 58 54 T 100 50 V62 H0Z`} fill={vivid ? f.near : "none"} stroke={vivid ? "none" : line} strokeWidth="0.8" />
      </svg>
      <p className={`mt-2 flex justify-between font-mono text-[11px] tracking-wide ${vivid ? "" : "text-neutral-400"}`}>
        <span>{f.code}</span>
        <span>{f.time}</span>
      </p>
    </li>
  );
}

/**
 * The whole hero, drawn twice with the same classes: once as dark line art (the real, focusable one)
 * and once in full colour (decorative, shown only through the lens).
 */
function Scene({ vivid, all, onToggle }: { vivid: boolean; all: boolean; onToggle?: () => void }) {
  const primary = "inline-flex h-12 items-center justify-center rounded-full px-7 text-sm font-semibold";
  const ring = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950";
  return (
    <div className="mx-auto flex w-full max-w-5xl flex-col items-center px-5 pb-12 pt-16 text-center sm:pt-20" style={vivid ? { color: INK } : undefined}>
      <p className={`inline-flex items-center gap-2 rounded-full px-3.5 py-1.5 text-xs font-semibold uppercase tracking-[0.18em] ${vivid ? "border border-transparent bg-[#16031c] text-white" : "border border-white/25 text-neutral-200"}`}>
        <span className={`h-1.5 w-1.5 rounded-full ${vivid ? "bg-[#ffd166]" : "bg-neutral-400"}`} aria-hidden="true" />
        Lumagrade 4
      </p>
      <h1
        className="mt-7 text-[13.5vw] font-black uppercase leading-[0.88] tracking-tighter sm:text-[84px] lg:text-[112px]"
        style={vivid ? undefined : { color: "#0a0a0a", WebkitTextStroke: "3px rgba(255,255,255,0.42)", paintOrder: "stroke fill" }}
      >
        {headline.map((l) => (
          <span key={l} className="block">
            {l}
          </span>
        ))}
      </h1>
      <p className={`mt-7 max-w-md text-base leading-relaxed sm:text-lg ${vivid ? "" : "text-neutral-300"}`}>
        Flat log footage goes in. Lumagrade finds the grade it was shot for, scene by scene. Move the lens to see what is underneath.
      </p>
      <div className="mt-8 flex w-full flex-col items-center justify-center gap-3 sm:w-auto sm:flex-row">
        {vivid ? (
          <>
            <span className={`${primary} w-full bg-[#16031c] text-white sm:w-auto`}>Start grading free</span>
            <span className={`${primary} w-full border-2 border-[#16031c] sm:w-auto`}>{all ? "Back to the flat cut" : "Reveal the whole grade"}</span>
          </>
        ) : (
          <>
            <a href="#" className={`${primary} ${ring} w-full bg-white text-neutral-950 sm:w-auto`}>
              Start grading free
            </a>
            <button type="button" aria-pressed={all} onClick={onToggle} className={`${primary} ${ring} w-full border-2 border-white/40 text-white sm:w-auto`}>
              {all ? "Back to the flat cut" : "Reveal the whole grade"}
            </button>
          </>
        )}
      </div>
      <ul className="mt-12 flex w-full gap-3 sm:gap-4">
        {frames.map((f, i) => (
          <Frame key={f.code} f={f} vivid={vivid} index={i} />
        ))}
      </ul>
    </div>
  );
}

export default function SpotlightRevealHeroWithHiddenColourLayer() {
  const reduce = !!useReducedMotion();
  const rootRef = useRef<HTMLElement>(null);
  const pointer = useRef<{ x: number; y: number } | null>(null);
  const [all, setAll] = useState(false);
  const allRef = useRef(false);
  allRef.current = all;

  const tx = useMotionValue(0);
  const ty = useMotionValue(0);
  const tr = useMotionValue(0);
  const x = useSpring(tx, { stiffness: 110, damping: 19, mass: 0.9 });
  const y = useSpring(ty, { stiffness: 110, damping: 19, mass: 0.9 });
  const r = useSpring(tr, { stiffness: 70, damping: 15, mass: 1 });
  // A hard-edged circle in a mask: everything inside the radius shows the colour layer, everything outside hides it.
  const mask = useMotionTemplate`radial-gradient(circle ${r}px at ${x}px ${y}px, #000 99.4%, transparent 100%)`;
  const ringSize = useMotionTemplate`calc(${r}px * 2)`;
  const tagOffset = useMotionTemplate`calc(${r}px * 0.72)`;

  useEffect(() => {
    const el = rootRef.current;
    if (!el) return;
    let frame = 0;
    let visible = true;
    const began = performance.now();
    const rest = () => Math.max(LENS_MIN, Math.min(LENS_MAX, el.clientWidth * LENS_SHARE));
    const place = (now: number) => {
      const w = el.clientWidth;
      const h = el.clientHeight;
      const t = ((now - began) / 1000) * ROAM_SPEED;
      const p = pointer.current;
      // At rest the lens roams on two sine waves of different speeds, which never quite repeats.
      tx.set(p ? p.x : w * (0.5 + 0.25 * Math.sin(t * 0.62 + 0.5)));
      ty.set(p ? p.y : h * (0.4 + 0.2 * Math.sin(t * 0.97 + 2.4)));
      const speed = Math.hypot(x.getVelocity(), y.getVelocity());
      const breath = 1 + Math.sin(t * 1.7) * 0.035;
      tr.set(allRef.current ? Math.hypot(w, h) : rest() * breath + Math.min(SPEED_SWELL, speed * 0.035));
    };
    if (reduce) {
      place(began);
      x.jump(tx.get());
      y.jump(ty.get());
      r.jump(tr.get());
      return;
    }
    x.jump(el.clientWidth * 0.62);
    y.jump(el.clientHeight * 0.36);
    const tick = (now: number) => {
      place(now);
      frame = requestAnimationFrame(tick);
    };
    const run = () => {
      cancelAnimationFrame(frame);
      if (visible && !document.hidden) frame = requestAnimationFrame(tick);
    };
    const view = new IntersectionObserver(([entry]) => {
      visible = entry.isIntersecting;
      run();
    });
    view.observe(el);
    document.addEventListener("visibilitychange", run);
    run();
    return () => {
      cancelAnimationFrame(frame);
      view.disconnect();
      document.removeEventListener("visibilitychange", run);
    };
  }, [reduce, tx, ty, tr, x, y, r]);

  // With motion reduced there is no loop, so the toggle and the pointer set the lens directly.
  useEffect(() => {
    const el = rootRef.current;
    if (!reduce || !el) return;
    r.jump(all ? Math.hypot(el.clientWidth, el.clientHeight) : Math.max(LENS_MIN, Math.min(LENS_MAX, el.clientWidth * LENS_SHARE)));
  }, [all, reduce, r]);

  const track = (e: React.PointerEvent<HTMLElement>) => {
    const b = e.currentTarget.getBoundingClientRect();
    pointer.current = { x: e.clientX - b.left, y: e.clientY - b.top };
    if (reduce) {
      x.jump(pointer.current.x);
      y.jump(pointer.current.y);
    }
  };
  const release = () => (pointer.current = null);

  return (
    <section
      ref={rootRef}
      className="relative w-full overflow-hidden bg-neutral-950 text-white"
      onPointerMove={track}
      onPointerDown={track}
      onPointerLeave={release}
      onPointerCancel={release}
      onPointerUp={(e) => {
        if (e.pointerType !== "mouse") release();
      }}
    >
      {/* Dark layer: a faint grid and the hero as line art. */}
      <div className="pointer-events-none absolute inset-0 opacity-60" style={{ backgroundImage: "linear-gradient(rgba(255,255,255,0.05) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.05) 1px, transparent 1px)", backgroundSize: "56px 56px" }} aria-hidden="true" />
      <div className="relative">
        <Scene vivid={false} all={all} onToggle={() => setAll((v) => !v)} />
      </div>

      {/* Colour layer: the same hero, painted, visible only inside the mask. */}
      <motion.div className="pointer-events-none absolute inset-0" style={{ maskImage: mask, WebkitMaskImage: mask }} aria-hidden="true">
        <div
          className="absolute inset-0"
          style={{
            background:
              "radial-gradient(60% 70% at 12% 8%, #ff3d81 0%, rgba(255,61,129,0) 70%), radial-gradient(55% 60% at 88% 12%, #ffc400 0%, rgba(255,196,0,0) 70%), radial-gradient(60% 70% at 82% 96%, #8b5cf6 0%, rgba(139,92,246,0) 70%), radial-gradient(55% 60% at 10% 98%, #00c8ff 0%, rgba(0,200,255,0) 70%), #ff7a3d",
          }}
        />
        <div className="absolute inset-0 opacity-25" style={{ backgroundImage: `radial-gradient(${INK} 1.2px, transparent 1.6px)`, backgroundSize: "16px 16px" }} />
        <div className="relative">
          <Scene vivid all={all} />
        </div>
      </motion.div>

      {/* The rim of the lens and its tag follow the same springs as the mask. */}
      <motion.div className="pointer-events-none absolute left-0 top-0" style={{ x, y, opacity: all ? 0 : 1 }} aria-hidden="true">
        <motion.div className="absolute rounded-full" style={{ width: ringSize, height: ringSize, left: 0, top: 0, x: "-50%", y: "-50%", boxShadow: "0 0 0 1px rgba(255,255,255,0.75), 0 0 0 5px rgba(255,255,255,0.08), 0 0 50px 4px rgba(255,122,61,0.45)" }} />
        <motion.span className="absolute whitespace-nowrap rounded-full bg-white px-2.5 py-1 font-mono text-[11px] font-semibold tracking-wide text-neutral-950" style={{ left: 0, top: 0, x: tagOffset, y: tagOffset }}>
          GRADED
        </motion.span>
      </motion.div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/spotlight-reveal-hero-with-hidden-colour-layer.json

When to use it

How to customize it

Accessibility

Questions

How does the cursor reveal effect work?

Two copies of the page sit on top of each other. The top copy has a CSS `mask-image` made of one `radial-gradient` whose centre and radius are driven by the pointer. Inside the circle the mask is opaque and the top copy shows; outside it is transparent and you see the copy underneath.

Why do the two layers line up so exactly?

Both are rendered by the same `Scene` function with the same layout classes. Only colours, fills and strokes differ between the two, so every letter and frame lands on the same pixel at any width.

How is the outlined headline made?

The dark copy fills the letters with the background colour, adds `-webkit-text-stroke` and sets `paint-order: stroke fill` so the fill hides the inner half of the stroke and any overlapping glyph contours. The colour copy simply uses a solid text colour.

Does animating a mask hurt performance?

The mask is a single gradient, so the browser repaints one layer as it moves. That is cheap at hero size. Avoid adding blur filters to the masked layer, which would make every frame much heavier.

Can the hidden layer be a photo or a video?

Yes. Replace the gradient block inside the masked layer with any element. Keep the layer absolutely positioned over the base so the lens coordinates still match.

Related: Dark Hero with Spotlight Sweep and Line Reveal, Spotlight Cards with Pointer Light, Liquid Metaball Cursor That Merges with Buttons.

More cursor effects or all free UI components. Start for free to have a full site built around it.

More in Cursors

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap