Skip to content
UI Kit
Accent color

Dot glow

A dot field whose dots light up in the accent color near the pointer or in a moving wave.

One of 6 components in Backgrounds

3 variants

Variants

Follows the pointer

Dots near the pointer glow. Without a pointer the light wanders slowly on its own.

Wave

A diagonal band of light sweeps across the field and runs on with the scroll.

Ripple

Calm rings spread from the middle, like breathing.

Details

What Dot glow brings along, read from the registry and the source.

Category
Backgrounds
Variants
3
Version
1.0.0 · since 1.0.0
File
components/ui/dot-glow.tsx
Brings along
utilskit
npm packages
None
Languages
Texts come through props
Studio
Adjust in code

Props

The DotGlowProps interface from components/ui/dot-glow.tsx, with the comments from the code.

  • modeoptional
    DotGlowMode

    pointerwaveripple

    pointer: dots near the pointer light up (on touch screens the light wanders on its own). wave: a diagonal band of light sweeps across the field. ripple: rings of light spread from the middle.

  • gapoptional
    number

    Distance between dots in px, default 24

  • radiusoptional
    number

    Reach of the glow in px, default 170

  • speedoptional
    number

    1 is calm, 2 is twice as fast

  • clearCenteroptional
    boolean

    Dims the glow behind the middle so a headline stays readable, default true

  • fadeoptional
    'edges' | 'none'

    How far the field fades out toward the edges

  • scrollLinkoptional
    boolean

    The light also moves with the scroll: the wave travels on, the rings spread, the wandering light keeps going. Default false.

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, 200 lines
components/ui/dot-glow.tsx
'use client';

import { useEffect, useRef } from 'react';
import { cn, prefersReducedMotion } from '@/lib/kit';

export type DotGlowMode = 'pointer' | 'wave' | 'ripple';

export interface DotGlowProps {
  /**
   * pointer: dots near the pointer light up (on touch screens the light wanders on its own).
   * wave: a diagonal band of light sweeps across the field.
   * ripple: rings of light spread from the middle.
   */
  mode?: DotGlowMode;
  /** Distance between dots in px, default 24 */
  gap?: number;
  /** Reach of the glow in px, default 170 */
  radius?: number;
  /** 1 is calm, 2 is twice as fast */
  speed?: number;
  /** Dims the glow behind the middle so a headline stays readable, default true */
  clearCenter?: boolean;
  /** How far the field fades out toward the edges */
  fade?: 'edges' | 'none';
  /** The light also moves with the scroll: the wave travels on, the rings spread, the wandering light keeps going. Default false. */
  scrollLink?: boolean;
  className?: string;
}

interface Colors { dot: string; glow: string; dark: boolean }

/** Reads the current accent as a color string the canvas understands */
function readColors(el: HTMLElement): Colors {
  const probe = document.createElement('span');
  probe.style.cssText = 'position:absolute;visibility:hidden;color:var(--primary)';
  el.appendChild(probe);
  const glow = getComputedStyle(probe).color;
  probe.style.color = 'color-mix(in oklab, var(--primary) 75%, var(--foreground))';
  const dot = getComputedStyle(probe).color;
  probe.remove();
  return { dot, glow, dark: !!el.closest('.dark') };
}

/**
 * A dot pattern whose dots glow in the accent color, near the pointer or in
 * a moving wave. The plain dots are CSS and render on the server, a canvas
 * on top draws only the glowing ones. DPR aware, sized by ResizeObserver,
 * paused out of view and in hidden tabs, a still frame with reduced motion.
 * Fills its parent and listens to the pointer on the parent, so it never
 * blocks clicks.
 */
export function DotGlow({ mode = 'pointer', gap = 24, radius = 170, speed = 1, clearCenter = true, fade = 'edges', scrollLink = false, className }: DotGlowProps) {
  const wrap = useRef<HTMLDivElement>(null);
  const canvas = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const host = wrap.current;
    const cv = canvas.current;
    const ctx = cv?.getContext('2d');
    if (!host || !cv || !ctx) return;
    const reduced = prefersReducedMotion();
    const finePointer = window.matchMedia('(hover: hover) and (pointer: fine)').matches;
    let colors = readColors(host);
    let w = 0, h = 0, dpr = 1;
    let frame = 0;
    let visible = true;
    // Start mid-motion, so the first frame already shows the effect
    const t0 = performance.now() - 2600 / speed;
    // Pointer state: target and eased position, null while no pointer is over the parent
    let target: { x: number; y: number } | null = null;
    const pos = { x: 0, y: 0 };
    // Scroll adds up to five seconds of motion over a full pass
    let pass = 0;
    const onScroll = () => {
      const r = host.getBoundingClientRect();
      pass = Math.min(1, Math.max(0, (window.innerHeight - r.top) / (r.height + window.innerHeight)));
    };
    if (scrollLink && !reduced) {
      onScroll();
      window.addEventListener('scroll', onScroll, { passive: true });
    }

    const draw = (time: number) => {
      const t = (time / 1000) * speed + (reduced ? 2.6 : 0) + pass * 5;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx.clearRect(0, 0, w, h);
      const narrow = w < 640;
      let px = w / 2, py = h * 0.45;
      if (mode === 'pointer') {
        const auto = { x: w / 2 + Math.sin(t * 0.45) * w * 0.3, y: h * 0.45 + Math.sin(t * 0.31 + 1) * h * 0.22 };
        const goal = target ?? (reduced ? { x: w / 2, y: h * 0.42 } : auto);
        pos.x += (goal.x - pos.x) * (target ? 0.16 : 0.05);
        pos.y += (goal.y - pos.y) * (target ? 0.16 : 0.05);
        px = pos.x;
        py = pos.y;
        // a soft halo under the dots
        const halo = ctx.createRadialGradient(px, py, 0, px, py, radius * 1.3);
        halo.addColorStop(0, colors.glow);
        halo.addColorStop(1, 'transparent');
        ctx.globalAlpha = colors.dark ? 0.1 : 0;
        ctx.fillStyle = halo;
        ctx.fillRect(px - radius * 1.3, py - radius * 1.3, radius * 2.6, radius * 2.6);
      }
      const ringR = ((t * 160) % (Math.hypot(w, h) * 0.6 + radius)) - radius / 2;
      const band = ((t * 170 + (w + h) * 0.3) % (w * 1.8 + radius * 4)) - radius * 2;
      ctx.fillStyle = colors.dot;
      for (let y = gap / 2; y < h; y += gap) {
        for (let x = gap / 2; x < w; x += gap) {
          let v = 0;
          if (mode === 'pointer') {
            const d = Math.hypot(x - px, y - py);
            v = d < radius ? (1 - d / radius) ** 2 : 0;
          } else if (mode === 'wave') {
            const u = (x + y * 0.8) - band;
            v = Math.exp(-((u / (radius * 0.7)) ** 2)) * (0.6 + 0.4 * Math.sin(x * 0.015 + t));
          } else {
            const d = Math.hypot(x - w / 2, y - h / 2);
            const a = Math.exp(-(((d - ringR) / (radius * 0.28)) ** 2));
            const b = Math.exp(-(((d - ringR + radius * 1.6) / (radius * 0.28)) ** 2)) * 0.55;
            v = Math.max(a, b) * Math.max(0, 1 - d / (Math.hypot(w, h) * 0.55));
          }
          // On phones the copy fills most of the width, so the quiet middle grows with it
          if (clearCenter) v *= 1 - (narrow ? 0.88 : 0.7) * Math.exp(-(((x - w / 2) / (w * (narrow ? 0.46 : 0.24))) ** 2 + ((y - h / 2) / (h * (narrow ? 0.3 : 0.26))) ** 2));
          if (v < 0.04) continue;
          ctx.globalAlpha = Math.min(1, v * 1.1);
          ctx.beginPath();
          ctx.arc(x, y, 1 + v * 1.25, 0, Math.PI * 2);
          ctx.fill();
        }
      }
      ctx.globalAlpha = 1;
    };

    const loop = (time: number) => {
      draw(time - t0);
      frame = requestAnimationFrame(loop);
    };
    const start = () => {
      if (reduced) { draw(0); return; }
      if (!frame && visible && !document.hidden) frame = requestAnimationFrame(loop);
    };
    const stop = () => { cancelAnimationFrame(frame); frame = 0; };

    const resize = () => {
      const r = host.getBoundingClientRect();
      dpr = Math.min(window.devicePixelRatio || 1, 2);
      w = Math.max(1, Math.round(r.width));
      h = Math.max(1, Math.round(r.height));
      cv.width = Math.round(w * dpr);
      cv.height = Math.round(h * dpr);
      if (!pos.x) { pos.x = w / 2; pos.y = h * 0.42; }
      if (reduced || !frame) draw(reduced ? 0 : performance.now() - t0);
    };
    const ro = new ResizeObserver(resize);
    ro.observe(host);
    resize();

    const io = new IntersectionObserver(([e]) => { visible = e.isIntersecting; if (visible) start(); else stop(); });
    io.observe(host);
    const onVis = () => (document.hidden ? stop() : start());
    document.addEventListener('visibilitychange', onVis);
    // Theme or accent switches change the colors
    const mo = new MutationObserver(() => { colors = readColors(host); if (reduced) start(); });
    mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'data-accent', 'style'] });

    const parent = host.parentElement;
    const move = (e: PointerEvent) => {
      if (e.pointerType === 'touch' || !finePointer) return;
      const r = host.getBoundingClientRect();
      target = { x: e.clientX - r.left, y: e.clientY - r.top };
    };
    const leave = () => { target = null; };
    if (mode === 'pointer' && parent && !reduced) {
      parent.addEventListener('pointermove', move);
      parent.addEventListener('pointerleave', leave);
    }
    start();
    return () => {
      stop();
      ro.disconnect();
      io.disconnect();
      mo.disconnect();
      document.removeEventListener('visibilitychange', onVis);
      window.removeEventListener('scroll', onScroll);
      parent?.removeEventListener('pointermove', move);
      parent?.removeEventListener('pointerleave', leave);
    };
  }, [mode, gap, radius, speed, clearCenter, scrollLink]);

  const mask = fade === 'edges' ? 'radial-gradient(ellipse 78% 74% at 50% 46%, #000 35%, transparent 82%)' : undefined;
  return (
    <div ref={wrap} aria-hidden className={cn('pointer-events-none absolute inset-0 overflow-hidden', className)} style={mask ? { maskImage: mask, WebkitMaskImage: mask } : undefined}>
      <div
        className="absolute inset-0 [--kitfx-dot:color-mix(in_oklch,var(--foreground)_15%,transparent)] dark:[--kitfx-dot:color-mix(in_oklch,var(--foreground)_17%,transparent)]"
        style={{ backgroundImage: 'radial-gradient(circle, var(--kitfx-dot) 1px, transparent 1.5px)', backgroundSize: `${gap}px ${gap}px` }}
      />
      <canvas ref={canvas} className="absolute inset-0 size-full" />
    </div>
  );
}

Frequent questions

How do I install Dot glow?

With aiw add @ai-website/dot-glow. The command puts components/ui/dot-glow.tsx into your project, the code is yours from then on. It needs no further npm packages.

Which variants does Dot glow have?

3 variants, Follows the pointer, Wave and Ripple.

Can an AI assistant build with Dot glow?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Dot glow with aiw_components and install it with aiw_add @ai-website/dot-glow.

Often searched

Jump to

Copied to the clipboard