Spotlight Effect in React and Tailwind With CSS Masks

6 min read

TL;DR Put a dark overlay over your content and mask it with a radial-gradient that is transparent at var(--spot-x) var(--spot-y). Register the three variables with @property so they can transition, write them with style.setProperty inside requestAnimationFrame instead of React state, and move the spot on mouse moves and on focus so keyboard and touch users get the light too.

On this page
  1. How does a CSS spotlight work?
  2. Why CSS variables instead of React state?
  3. The CSS: three registered variables and one mask
  4. The Spotlight component
  5. Using it on a sign-in page
  6. Is a spotlight bad for accessibility?
  7. Bugs to check for in AI-generated spotlight code
  8. Do you need Framer Motion for this?
  9. The full version
  10. FAQ
  11. How do I make a spotlight effect follow the mouse in React?
  12. Can I build a spotlight effect with only Tailwind?
  13. Why doesn't my CSS variable transition work?
  14. Does a spotlight effect hurt performance?

A spotlight effect is a dark layer over your UI with a soft round hole in it, and the hole follows the mouse or the field you're typing in. In React and Tailwind you can build it with one CSS mask: mask-image: radial-gradient(...) on an overlay, three CSS variables for the hole's position and size, and a pointer handler that writes those variables straight to the DOM. No canvas, no library and no re-render per mouse move.

I make qodesign, a set of animated React + TypeScript + Tailwind components. Our Spotlight Lamp Login is a sign-in page lit by a desk lamp, where only what the beam touches is visible and the beam swings to whichever field has focus. The component itself draws its beam with SVG and uses Framer Motion. The code below is a smaller version written for this post. It uses only CSS and React, and it compiles with TypeScript in strict mode against React 19 and Next.js 15.

How does a CSS spotlight work?

You stack two things. The content sits underneath. On top goes a full-size dark layer with pointer-events: none, so clicks pass through it. Then you mask that dark layer.

A mask decides which parts of an element show. Where the mask image is opaque, the element shows. Where it's transparent, the element disappears. So a radial gradient that is transparent in the middle and black at the edge cuts a soft round hole in the dark layer, and the content shows through the hole.

mask-image works unprefixed in every current browser. MDN lists it as Baseline, widely available since December 2023.

Spotlight Lamp Login running live. A sign-in page in the dark: a desk lamp finds each field and floods the screen when you sign in.

Why CSS variables instead of React state?

A mouse fires pointermove dozens of times a second. If each event calls setState, React re-renders the component and everything inside it on every move, and a login form full of inputs is a lot to redo. The position only matters to one CSS rule, so write it to that element directly with style.setProperty and let the browser repaint. Wrap the write in requestAnimationFrame so you set it at most once per frame. The same trick drives Eyes That Follow the Cursor in React and Tailwind, where a mascot on a sign-in card turns its pupils toward the pointer and the text caret without a single re-render.

The CSS: three registered variables and one mask

Put this in your global stylesheet (app/globals.css in a Next.js app):

css
@property --spot-x {
  syntax: "<length>";
  inherits: false;
  initial-value: 0px;
}
@property --spot-y {
  syntax: "<length>";
  inherits: false;
  initial-value: 0px;
}
@property --spot-r {
  syntax: "<length>";
  inherits: false;
  initial-value: 180px;
}

.spotlight-dark {
  mask-image: radial-gradient(
    circle var(--spot-r) at var(--spot-x) var(--spot-y),
    transparent 35%,
    black 100%
  );
  transition: --spot-x 0.35s ease-out, --spot-y 0.35s ease-out, --spot-r 0.35s ease-out;
}

@property registers each variable as a length. That's what makes the transition line work. A plain custom property is just text to the browser, so it can't animate between two values and the hole would jump. A registered <length> can be interpolated, so the hole glides. MDN marks @property as Baseline 2024, newly available since July 2024, so very old browsers will get the jump and nothing worse.

The gradient keeps the inner 35% of the radius fully clear and fades to dark at the edge. Raise the 35% for a harder edge.

If you prefer to keep it in class names, Tailwind v4 has mask utilities too, including mask-radial-* and mask-(--my-var), which sets mask-image from a custom property. A plain rule is easier to read for this one.

The Spotlight component

tsx
"use client";

import { useEffect, useRef, type FocusEvent, type PointerEvent, type ReactNode } from "react";

type SpotlightProps = {
  children: ReactNode;
  radius?: number;
  className?: string;
};

export function Spotlight({ children, radius = 180, className = "" }: SpotlightProps) {
  const darkRef = useRef<HTMLDivElement>(null);
  const frame = useRef(0);

  function moveTo(x: number, y: number, r: number) {
    const dark = darkRef.current;
    if (!dark) return;
    cancelAnimationFrame(frame.current);
    frame.current = requestAnimationFrame(() => {
      dark.style.setProperty("--spot-x", `${x}px`);
      dark.style.setProperty("--spot-y", `${y}px`);
      dark.style.setProperty("--spot-r", `${r}px`);
    });
  }

  useEffect(() => {
    const dark = darkRef.current;
    if (dark) moveTo(dark.clientWidth / 2, dark.clientHeight / 4, radius);
    return () => cancelAnimationFrame(frame.current);
  }, [radius]);

  function onPointerMove(e: PointerEvent<HTMLDivElement>) {
    if (e.pointerType !== "mouse") return;
    const box = e.currentTarget.getBoundingClientRect();
    moveTo(e.clientX - box.left, e.clientY - box.top, radius);
  }

  function onFocus(e: FocusEvent<HTMLDivElement>) {
    const box = e.currentTarget.getBoundingClientRect();
    const field = e.target.getBoundingClientRect();
    const isPassword = e.target instanceof HTMLInputElement && e.target.type === "password";
    moveTo(
      field.left - box.left + field.width / 2,
      field.top - box.top + field.height / 2,
      isPassword ? radius * 0.6 : radius,
    );
  }

  return (
    <div
      onPointerMove={onPointerMove}
      onFocus={onFocus}
      className={`relative isolate overflow-hidden ${className}`}
    >
      {children}
      <div
        ref={darkRef}
        aria-hidden="true"
        className="spotlight-dark pointer-events-none absolute inset-0 bg-black/90 motion-reduce:hidden"
      />
    </div>
  );
}

What each part does:

  • The hole follows the mouse only when pointerType is "mouse". On a phone there's no hover, and a finger drag would fight the page scroll.
  • React's onFocus bubbles, so one handler on the wrapper catches focus on every input and button inside it. That's the lamp trick. Keyboard users and phone users get a light that moves to the field they're in.
  • On a password field the radius shrinks to 60%, which reads as the light narrowing.
  • Positions are measured relative to the wrapper with getBoundingClientRect. clientX alone is relative to the viewport and puts the hole in the wrong place as soon as the wrapper isn't at the top left of the page.
  • The dark layer has aria-hidden and pointer-events-none. Screen readers skip it and every click reaches the form.
  • motion-reduce:hidden removes the dark layer for anyone who has asked their system to reduce motion. A moving light is motion, and the page is perfectly usable fully lit.

Using it on a sign-in page

The page stays a Server Component. Only Spotlight needs "use client", and it takes the form as children:

tsx
import { Spotlight } from "./Spotlight";

export default function SignInPage() {
  return (
    <main className="grid min-h-dvh place-items-center bg-zinc-950 p-6">
      <Spotlight className="w-full max-w-sm rounded-2xl">
        <form className="space-y-4 rounded-2xl border border-white/10 bg-zinc-900 p-8 text-zinc-100">
          <h1 className="text-xl font-semibold">Welcome back</h1>
          <label className="block text-sm">
            Email
            <input
              type="email"
              name="email"
              autoComplete="email"
              required
              className="mt-1 w-full rounded-lg bg-zinc-800 px-3 py-2"
            />
          </label>
          <label className="block text-sm">
            Password
            <input
              type="password"
              name="password"
              autoComplete="current-password"
              required
              className="mt-1 w-full rounded-lg bg-zinc-800 px-3 py-2"
            />
          </label>
          <button type="submit" className="w-full rounded-lg bg-lime-300 py-2 font-medium text-zinc-950">
            Sign in
          </button>
        </form>
      </Spotlight>
    </main>
  );
}

Tab through it with the keyboard. The light should land on the email field, narrow on the password and move to the button. If it ever leaves the focused element in the dark, the layout changed after the wrapper was measured, so measure on focus (as above) and never cache the rectangles.

Is a spotlight bad for accessibility?

It can be. Everything outside the hole sits behind a 90% black layer, so that text fails contrast while it's dark. Two rules keep it fair:

  1. The element with focus is always lit. Following focus does this for you.
  2. Nothing the user needs to read lives only in the dark. Error messages go next to the field they belong to, inside the lit area, and get announced the usual way. Our post on accessible animated login forms covers labels, errors and the shake animation.

For more on the reduced motion side, see prefers-reduced-motion in React.

Bugs to check for in AI-generated spotlight code

If Cursor, v0 or Lovable wrote your spotlight, check for these five:

  1. setState on every mouse move. It works, then the form starts to lag as it grows. Use a ref and setProperty.
  2. The mask the wrong way round. black in the centre and transparent outside shows a dark disc on a clear page. The centre of the gradient has to be transparent.
  3. The overlay swallows clicks. Without pointer-events-none nobody can type in the form.
  4. A transition on an unregistered custom property. The code looks right and the hole still jumps. Register the variables with @property.
  5. Mouse only. The light never moves for keyboard or touch users, so half the form stays dark. Follow focus as well.

A prompt that avoids all five:

text
Add a spotlight effect to this sign-in form. Wrap the form in a client component with a full-size overlay div on top (bg-black/90, pointer-events-none, aria-hidden, motion-reduce:hidden). Mask the overlay with a radial-gradient that is transparent in the centre, using CSS variables --spot-x, --spot-y and --spot-r registered with @property as lengths so they can transition. Update the variables with style.setProperty inside requestAnimationFrame, never with React state. Move the spot on pointermove only for mouse pointers, and on focus (bubbling onFocus on the wrapper) to the centre of the focused element, measured relative to the wrapper. Narrow the radius on password inputs.

Do you need Framer Motion for this?

No. Everything above is a mask and a CSS transition. Reach for a motion library when the light has to do more than slide, like a lamp head that swings on a spring or a flash that floods the screen on success. That split, CSS for moving between two states and a library for choreography, is the same one I use in Framer Motion vs CSS for micro-interactions.

The full version

Spotlight Lamp Login ($3) is what this turns into with a lamp attached. The lamp flickers on and lifts its head, the beam follows the field you're typing in and narrows to a slit on the password, and signing in floods the screen while the card shrinks into a pill. It's a real form with labels, autocomplete, show/hide password and an error state, it ships as a single HTML file and as a React + TypeScript + Tailwind version, and it comes with a PROMPT.md for your AI editor. There's a guide for wiring it into a Next.js sign-in page on our spotlight login page.

FAQ

How do I make a spotlight effect follow the mouse in React?

Put a dark overlay on top of your content and mask it with a radial-gradient that is transparent at var(--spot-x) var(--spot-y). In a pointermove handler, subtract the wrapper's getBoundingClientRect() from clientX and clientY and write the result to those variables with style.setProperty, inside requestAnimationFrame.

Can I build a spotlight effect with only Tailwind?

Mostly. Tailwind v4 has mask utilities such as mask-radial-* and mask-(--my-var), and you can set the variables from React. You still need a few lines of JavaScript to track the pointer or focus, and a small @property block in your CSS if you want the hole to glide.

Why doesn't my CSS variable transition work?

The browser can't animate an unregistered custom property, because it doesn't know what type the value is. Register it with @property and a syntax such as "<length>", then list it in transition like any other property.

Does a spotlight effect hurt performance?

Not much, if you avoid React state. Writing a CSS variable once per animation frame leaves React out of it, and the browser only has to repaint the overlay. Re-rendering the whole form on every mouse move is what makes these effects feel heavy.