Skip to content
UI Kit
Accent color

Spotlight follow

A stage whose light follows the pointer and rests in the middle on touch screens.

One of 6 components in Backgrounds

3 variants

Variants

Soft light

Plain light without a pattern, calm enough for a law firm.

Grid reveal

The light uncovers a grid in the accent color.

Dot reveal

A wider light over a dot field, resting a little higher.

Details

What Spotlight follow brings along, read from the registry and the source.

Category
Backgrounds
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

Props

The SpotlightFollowProps interface from components/ui/spotlight-follow.tsx, with the comments from the code. Also takes every prop of React.HTMLAttributes<HTMLDivElement>.

  • sizeoptional
    number

    Diameter of the light in px, default 640

  • toneoptional
    SpotlightTone

    accentneutral

    accent tints the light with the accent color, neutral is plain light

  • revealoptional
    SpotlightReveal

    nonegriddots

    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

  • childrenoptional
    React.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
components/ui/spotlight-follow.tsx
'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.

Often searched

Jump to

Copied to the clipboard