Backgrounds

Spotlight

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

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

/** 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"> {
  /** Spotlight diameter in px. */

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
- export function Spotlight(props): props extend React.ComponentProps<"div">
  (rest props spread onto the root) plus:
  - size?: number — spotlight diameter in px (default 320).
  - 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, holding a ref that is both
  attached to the element and used to write pointer coordinates directly.
- On mousemove, compute the cursor position relative to the root's bounding
  rect and write it straight into two CSS custom properties, --spot-x and
  --spot-y, via ref.current.style.setProperty — never through React state.
  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.
- 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: check window.matchMedia("(prefers-reduced-motion:
  reduce)") once in an effect (with a change listener, cleaned up on
  unmount) and cache the result in a ref. When reduced motion is on, the
  mousemove handler returns early and never writes --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: the reduced-motion media-query change listener is removed on
  unmount.

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, skipping React state so a fast mousemove stream never re-renders the children subtree.
  • 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