Backgrounds

Spotlight

A cursor-following radial glow container for section backgrounds — CSS-var tracking with zero re-renders.

Preview in your theme

Loading preview…

Registry dependencies
utils, use-prefers-reduced-motion
"use client"

import * as React from "react"
import { cn } from "@/lib/utils"
import { getPrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion"

/** color-mix alpha per intensity — the light layer's only color knob. */
const ALPHA = {
  subtle: "12%",
  medium: "20%",
  bold: "30%",
} as const

export interface SpotlightProps extends React.ComponentProps<"div"> {

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/spotlight.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "Spotlight" component — a section
background that paints a soft radial glow following the cursor. Its only
dependency is a cn() class merger (clsx + tailwind-merge).

Contract
- Shared primitive: use-prefers-reduced-motion (installed automatically via
  registryDependencies) — `getPrefersReducedMotion()` is the one-shot
  reduced-motion read in the mousemove handler. If rebuilding without it: read
  matchMedia("(prefers-reduced-motion: reduce)").matches at the moment of the
  event.
- export function Spotlight(props): props extend React.ComponentProps<"div">
  (rest props spread onto the root, so a consumer ref lands on it) plus:
  - size?: number — gradient radius in px (default 320). The glow fades to
    transparent at 60% of it, so the lit disc is about 1.2 x size across.
  - intensity?: "subtle" | "medium" | "bold" (default "medium") — selects a
    color-mix alpha tier for the glow: 12% / 20% / 30%.
  - children render above the effect layer; className merges onto the root.
- "use client" — the component owns pointer events and reads
  prefers-reduced-motion, so it cannot stay a server component.

Behavior
- Root div: relative isolate overflow-hidden. It carries no internal ref, so a
  ref passed by the consumer is never overridden.
- On mousemove, compute the cursor position relative to the root's bounding
  rect (e.currentTarget) and write it straight into two CSS custom properties,
  --spot-x and --spot-y, via lightRef.current.style.setProperty on the light
  layer — never through React state, and never on the root. This is the
  performance contract: a fast mousemove stream updates paint only, so the
  component (and everything nested inside it) never re-renders while the
  cursor moves, and because the light layer is a leaf, the custom-property
  write restyles that one element instead of the whole content subtree.
- A single boolean `active` state (React state is fine here — it only flips
  on mouseenter/mouseleave, a low-frequency boundary event) drives a CSS
  opacity transition on the light layer: fade in on enter, fade out on
  leave.
- The light layer is absolute inset-0, aria-hidden, pointer-events-none,
  with a radial-gradient background sized at `${size}px` centered on
  `var(--spot-x, 50%) var(--spot-y, 50%)`. The 50%/50% fallback matters: it
  is what the effect resolves to whenever the CSS vars are never written.
- prefers-reduced-motion: read it at the moment of each mousemove
  (getPrefersReducedMotion(), one shared MediaQueryList, no subscription and
  no re-render). When reduced motion is on, the handler skips the write and
  never sets --spot-x/--spot-y, so the gradient falls back to a fixed center —
  the glow stays visible as a static decoration but stops chasing the cursor.
- Touch / no-hover devices: the light layer carries a
  [@media(hover:none)]:hidden class so it is absent by default wherever
  there is no cursor to spotlight; content is never affected.
- Cleanup: nothing to release — the component creates no listener, timer or
  observer of its own.

Rendering & styling
- Semantic tokens only. The glow is radial-gradient(${size}px at
  var(--spot-x, 50%) var(--spot-y, 50%), color-mix(in oklab, var(--primary)
  N%, transparent), transparent 60%) with N from the intensity tier
  (12/20/30). No hex / rgb() / oklch() literals — the color adapts to the
  host theme and dark mode for free.
- Merge consumer className via cn() on the root div.
- Content sits in a relative z-10 wrapper above the light layer so the glow
  can never intercept clicks or enter the accessibility tree.

Customization levers
- Palette: swap var(--primary) for any other theme token (--chart-1..5,
  --accent) to change the glow's hue without touching the tracking logic.
- Multiple spotlights: render more than one light-layer div, each with its
  own CSS var pair, for a multi-cursor or multi-hotspot effect.
- Size / intensity: size and the ALPHA map are the only two knobs — widen
  the 12/20/30 band for a louder or quieter glow.
- Follow inertia: the base version tracks 1:1 with no easing; upgrading to a
  spring/lerp follow (e.g. via requestAnimationFrame or a motion library) is
  a drop-in swap inside the mousemove handler — the CSS var contract stays
  the same.
- Composition: nest GridDots or any other background as a child — Spotlight
  only owns the glow layer and the z-10 content slot, so it composes with
  whatever pattern sits underneath.

Concepts

  • Ref-driven CSS variables — pointer coordinates land straight in --spot-x/--spot-y via style.setProperty on the light layer, skipping React state so a fast mousemove stream never re-renders the children subtree. The layer is a leaf, so the inherited custom properties never invalidate the content's styles either.
  • Token-derived glow — the light layer's color is color-mix(in oklab, var(--primary) N%, transparent), so the glow follows the host theme and dark mode automatically; N is 12/20/30 by intensity.
  • Low-frequency fade — hover enter/leave is the one thing that goes through useState; it only toggles opacity, so the tracked movement itself never touches render.
  • Reduced-motion honesty — the CSS vars are never written under prefers-reduced-motion: reduce (fallback var(--spot-x, 50%)), so the glow stays centered and visible instead of chasing a cursor no one asked it to chase.
  • Touch-safe default — (hover: none) hides the light layer entirely on touch devices, since there is no cursor to spotlight and no point leaving a gradient stuck mid-screen.
  • Layer separation — decoration is aria-hidden pointer-events-none absolute inset-0; content sits in a relative z-10 wrapper, so the glow can never intercept clicks or enter the accessibility tree.

On This Page