Spotlight follow
A stage whose light follows the pointer and rests in the middle on touch screens.
One of 6 components in Backgrounds
Variants
Details
What Spotlight follow brings along, read from the registry and the source.
- Category
- Backgrounds
- Area
- Elements
- Variants
- 3
- Version
- 1.0.0 · since 1.0.0
- File
- components/ui/spotlight-follow.tsx
- Brings along
- utilskit
- npm packages
- None
- Languages
- Texts come through props
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The SpotlightFollowProps interface from components/ui/spotlight-follow.tsx, with the comments from the code. Also takes every prop of React.HTMLAttributes<HTMLDivElement>.
sizeoptionalnumberDiameter of the light in px, default 640
toneoptionalSpotlightToneaccentneutral
accent tints the light with the accent color, neutral is plain light
revealoptionalSpotlightRevealnonegriddots
A pattern that only shows where the light falls
intensityoptional'soft' | 'strong'soft for long reading sections, strong for a hero
restoptional{ x: number; y: number }Where the light rests without a pointer (touch, reduced motion, before the first move), in % of the stage
childrenoptionalReact.ReactNode
Accessibility
Read from the source of the component.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Moves with the scroll, its progress follows the position on the page.
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 142 lines
'use client';
import { useEffect, useRef } from 'react';
import { cn, prefersReducedMotion } from '@/lib/kit';
export type SpotlightTone = 'accent' | 'neutral';
export type SpotlightReveal = 'none' | 'grid' | 'dots';
export interface SpotlightFollowProps extends React.HTMLAttributes<HTMLDivElement> {
/** Diameter of the light in px, default 640 */
size?: number;
/** accent tints the light with the accent color, neutral is plain light */
tone?: SpotlightTone;
/** A pattern that only shows where the light falls */
reveal?: SpotlightReveal;
/** soft for long reading sections, strong for a hero */
intensity?: 'soft' | 'strong';
/** Where the light rests without a pointer (touch, reduced motion, before the first move), in % of the stage */
rest?: { x: number; y: number };
children?: React.ReactNode;
}
/* Strength of the glow in dark mode. Light mode always uses white light on the surface. */
const STRENGTH = { soft: 14, strong: 22 };
/**
* A stage whose light follows the pointer. Wraps its children. On touch
* screens the light travels down the stage with the scroll. With reduced
* motion and before the pointer arrives it rests in a fixed place, so the section always looks finished. The light
* moves with transforms only and eases toward the pointer.
*/
export function SpotlightFollow({ size = 640, tone = 'accent', reveal = 'none', intensity = 'soft', rest = { x: 50, y: 38 }, className, children, ...props }: SpotlightFollowProps) {
const stage = useRef<HTMLDivElement>(null);
const light = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = stage.current;
const lamp = light.current;
if (!el || !lamp) return;
if (prefersReducedMotion()) return;
const place = (dx: number, dy: number) => {
lamp.style.transform = `translate3d(${dx.toFixed(1)}px, ${dy.toFixed(1)}px, 0)`;
if (reveal !== 'none') {
const r = el.getBoundingClientRect();
el.style.setProperty('--kitfx-sx', `${((rest.x / 100) * r.width + dx).toFixed(1)}px`);
el.style.setProperty('--kitfx-sy', `${((rest.y / 100) * r.height + dy).toFixed(1)}px`);
}
};
// Touch screens: no pointer to follow, the light travels down the stage with the scroll instead
if (!window.matchMedia('(hover: hover) and (pointer: fine)').matches) {
let raf = 0;
const update = () => {
raf = 0;
const r = el.getBoundingClientRect();
const p = Math.min(1, Math.max(0, (window.innerHeight - r.top) / (r.height + window.innerHeight)));
place(0, (p - 0.5) * r.height * 0.5);
};
const onScroll = () => {
if (!raf) raf = requestAnimationFrame(update);
};
update();
window.addEventListener('scroll', onScroll, { passive: true });
return () => {
cancelAnimationFrame(raf);
window.removeEventListener('scroll', onScroll);
};
}
let frame = 0;
let x = 0, y = 0, tx = 0, ty = 0; // offsets from the resting point in px
const tick = () => {
x += (tx - x) * 0.14;
y += (ty - y) * 0.14;
place(x, y);
frame = Math.abs(tx - x) + Math.abs(ty - y) > 0.4 ? requestAnimationFrame(tick) : 0;
};
const go = () => { if (!frame) frame = requestAnimationFrame(tick); };
const move = (e: PointerEvent) => {
if (e.pointerType === 'touch') return;
const r = el.getBoundingClientRect();
tx = e.clientX - r.left - (rest.x / 100) * r.width;
ty = e.clientY - r.top - (rest.y / 100) * r.height;
go();
};
const leave = () => {
tx = 0;
ty = 0;
go();
};
el.addEventListener('pointermove', move);
el.addEventListener('pointerleave', leave);
return () => {
el.removeEventListener('pointermove', move);
el.removeEventListener('pointerleave', leave);
cancelAnimationFrame(frame);
};
}, [rest.x, rest.y, reveal]);
const s = STRENGTH[intensity];
const color = tone === 'accent' ? 'var(--primary)' : 'var(--foreground)';
// The revealed pattern takes the accent where the light falls
const ink = tone === 'accent' ? 'color-mix(in oklab, var(--primary) 70%, var(--foreground))' : 'var(--foreground)';
const patternOf = (a: number) =>
reveal === 'grid'
? `linear-gradient(to right, color-mix(in oklch, ${ink} ${a}%, transparent) 1px, transparent 1px), linear-gradient(to bottom, color-mix(in oklch, ${ink} ${a}%, transparent) 1px, transparent 1px)`
: `radial-gradient(color-mix(in oklch, ${ink} ${a * 2}%, transparent) 1px, transparent 1.5px)`;
const tile = reveal === 'grid' ? '48px 48px' : '20px 20px';
const revealMask = `radial-gradient(${Math.round(size * 0.4)}px circle at var(--kitfx-sx, ${rest.x}%) var(--kitfx-sy, ${rest.y}%), #000, transparent)`;
const baseMask = 'radial-gradient(ellipse 80% 80% at 50% 40%, #000, transparent)';
return (
<div ref={stage} {...props} className={cn('relative isolate overflow-hidden bg-surface-2 dark:bg-background', className)}>
<div aria-hidden data-clip-ok className="pointer-events-none absolute inset-0 -z-10 overflow-hidden">
{reveal !== 'none' && (
<>
{/* The faint pattern everywhere, and the bright one under the light */}
<div className="absolute inset-0" style={{ backgroundImage: patternOf(6), backgroundSize: tile, backgroundPosition: 'center', maskImage: baseMask, WebkitMaskImage: baseMask }} />
<div className="absolute inset-0" style={{ backgroundImage: patternOf(30), backgroundSize: tile, backgroundPosition: 'center', maskImage: revealMask, WebkitMaskImage: revealMask }} />
</>
)}
<div
ref={light}
className="absolute rounded-full will-change-transform"
style={{ left: `${rest.x}%`, top: `${rest.y}%`, width: size, height: size, marginLeft: -size / 2, marginTop: -size / 2 }}
>
{/* Light mode: white light on a quiet surface. Dark mode: the accent glows. */}
<div
className="absolute inset-0 rounded-full [--kitfx-c:var(--kitfx-cl)] [--kitfx-e:var(--kitfx-el)] dark:[--kitfx-c:var(--kitfx-cd)] dark:[--kitfx-e:var(--kitfx-ed)]"
style={{
['--kitfx-cl' as string]: 'var(--background)',
['--kitfx-el' as string]: 'color-mix(in oklch, var(--background) 55%, transparent)',
['--kitfx-cd' as string]: `color-mix(in oklch, ${color} ${s}%, transparent)`,
['--kitfx-ed' as string]: `color-mix(in oklch, ${color} ${Math.round(s * 0.35)}%, transparent)`,
background: 'radial-gradient(closest-side, var(--kitfx-c), var(--kitfx-e) 50%, transparent)',
}}
/>
</div>
</div>
{children}
</div>
);
}Frequent questions
How do I install Spotlight follow?
With aiw add @ai-website/spotlight-follow. The command puts components/ui/spotlight-follow.tsx into your project, the code is yours from then on. It needs no further npm packages.
Which variants does Spotlight follow have?
3 variants, Soft light, Grid reveal and Dot reveal.
Can an AI assistant build with Spotlight follow?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Spotlight follow with aiw_components and install it with aiw_add @ai-website/spotlight-follow.