Pointer light
StableA soft pool of light that follows the mouse over cards, and a lens that shows a brighter copy of a background.
Two hooks write the pointer position into CSS variables, and two classes draw the light. usePointerSpotlight serves every .lux-spot inside a root from one listener; usePointerVars feeds --px and --py to effects like .lux-lens. Both follow a mouse only, write at most once per frame and stay off when motion is reduced.
Spotlight on cards
import { useRef } from 'react'import { usePointerSpotlight } from '@rchursin/components/motion'
/** * Move a mouse over the cards: a soft pool of light follows it. One listener on the root serves * every .lux-spot inside it. Touch and keyboard users see the cards without the light. */const features = [ { title: 'Usefulness score', body: 'Sessions that used a skill, next to sessions that didn’t.' }, { title: 'Trigger check', body: 'Skills the agent never picks on its own.' }, { title: 'Overlap finder', body: 'Two skills, one job. Merge them.' },]
export default function PointerSpotlight() { const root = useRef<HTMLUListElement>(null) usePointerSpotlight(root) return ( <ul ref={root} className="grid w-full max-w-3xl gap-4 sm:grid-cols-3"> {features.map((f) => ( <li key={f.title} className="lux-card lux-spot flex flex-col gap-2 rounded-2xl bg-raised p-5"> <h3 className="text-[15px] font-semibold tracking-[-0.01em] text-fg">{f.title}</h3> <p className="text-[13px]/5 text-pretty text-fg-2">{f.body}</p> </li> ))} </ul> )}Usage
import { usePointerSpotlight, usePointerVars } from '@rchursin/components/motion'const root = useRef<HTMLDivElement>(null)usePointerSpotlight(root)
return ( <div ref={root}> <article className="lux-card lux-spot rounded-2xl p-6">…</article> </div>)Examples
Lens
A faint dot grid with a brighter copy that shows through a circle around the pointer. Without a mouse only the faint grid shows.
import { useRef } from 'react'import { usePointerVars } from '@rchursin/components/motion'
/** * A lens of light: usePointerVars writes the pointer position into --px / --py and sets * data-pointer on the root, and .lux-lens shows a brighter copy of the background only around * the pointer. Without a mouse (or with reduced motion) the dim layer is all there is. */export default function PointerLens() { const root = useRef<HTMLDivElement>(null) usePointerVars(root) return ( <div ref={root} className="relative isolate flex h-72 w-full flex-col items-center justify-center gap-2 overflow-hidden rounded-2xl bg-surface px-6 text-center ring-1 ring-line" > {/* The same background twice: dim everywhere, bright inside the lens */} <div aria-hidden="true" className="fx-dots absolute inset-0 -z-10" /> <div aria-hidden="true" className="fx-dots lux-lens absolute inset-0 -z-10 [--dot:var(--rc-fg)]" /> <h3 className="text-[22px]/[1.2] font-semibold tracking-[-0.025em] text-balance text-fg">Find the skills that work</h3> <p className="max-w-xs text-[13.5px]/6 text-pretty text-fg-2">The dots under the pointer light up. Everything else stays as it is.</p> </div> )}API
usePointerSpotlight(root)
| Prop | Type | Default | Description |
|---|---|---|---|
rootrequired | RefObject<HTMLElement | null> | – | Where to listen. Each .lux-spot under the pointer gets --x and --y: the pointer position in px from its own top-left corner. |
usePointerVars(ref)
| Prop | Type | Default | Description |
|---|---|---|---|
refrequired | RefObject<HTMLElement | null> | – | The element to track. It gets --px and --py (pointer position in px) and a data-pointer attribute while a mouse moves over it. |
CSS
| Class or variable | What it does |
|---|---|
lux-spot | A ::after layer with a 420px radial light at --x / --y in --lux-spot. It fades in on hover (fine pointers only) and out more slowly. The element needs a position; lux-card gives it one. |
lux-lens | Hidden until an ancestor has data-pointer, then visible through a 220px circular mask centred on --px / --py. |
--lux-spot | The light’s colour: 3.5% black, or 6% white in dark mode. Set your own on dark panels, e.g. [--lux-spot:rgb(255_255_255/0.07)]. |
Accessibility
- Mouse only: touch and pen moves are ignored, and neither hook does anything unless the device has hover, a fine pointer and motion allowed.
- Keyboard and touch users see the same cards and the dim background without the light. Nothing is hidden behind it, so keep it decoration.
- With reduced motion the light no longer follows the mouse, but hovering a .lux-spot still fades in a still glow at its top centre.
- Mark lens layers aria-hidden: they repeat what is already on the page.
Guidelines
Do
- Call the hook once on a page root (useLuxe already includes the spotlight) rather than once per card.
- Use it on cards that already work without it.
Don’t
- Don’t put information in the light or the lens.
- Don’t use the light as the hover or focus state; cards need their own.
In use
Used on this site
Related
- Cards & elevationCards on one four-step elevation scale: lux-card, a lower lux-card-flat, lux-lift on hover and lux-float for overlays.
- Scroll arrivalsContent that fades and rises in as it scrolls into view, bars that grow, stamps that land and a frame that draws itself.
- BackgroundsDot and grid patterns, masks and vignettes to fade them, a retro floor grid, a spotlight, a lamp and a softly lit card.
Source
The component’s own files, exactly as they run on this site.
/** * Small motion helpers for the luxe layer (see styles/luxe.css). No animation library. * Every helper leaves content visible and still when motion is reduced or JS hasn't run. */import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, type RefObject,} from "react"import { flushSync } from "react-dom"
const REVEAL_SELECTOR = ".lux-reveal, .lux-frame, .lux-stamp, .lux-grow"
/** true when the visitor allows motion (re-evaluated on change) */export function useMotionOK() { const [ok, setOk] = useState(false) useEffect(() => {