# Spotlight Effect in React and Tailwind With CSS Masks

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

- URL: https://qodesign.dev/blog/spotlight-effect-react-tailwind-css-mask
- Author: Teodor Avadani (https://qodesign.dev/blog/author/teodor-avadani)
- Published: 2026-10-21
- Updated: 2026-10-21
- Category: Animation
- Publisher: qodesign (https://qodesign.dev), a shop of animated React + Tailwind CSS components for Next.js, each sold with its full source code and an AI prompt.

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](/components/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](https://developer.mozilla.org/en-US/docs/Web/CSS/mask-image) lists it as Baseline, widely available since December 2023.

## 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](/blog/eyes-follow-cursor-react-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`](https://developer.mozilla.org/en-US/docs/Web/CSS/@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](https://tailwindcss.com/docs/mask-image) 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](/blog/accessible-animated-login-form-react) covers labels, errors and the shake animation.

For more on the reduced motion side, see [prefers-reduced-motion in React](/blog/prefers-reduced-motion-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](/blog/framer-motion-vs-css-micro-interactions).

## The full version

[Spotlight Lamp Login](/components/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](/react/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.
