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
Variants
Details
What Dot glow 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/dot-glow.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 DotGlowProps interface from components/ui/dot-glow.tsx, with the comments from the code.
modeoptionalDotGlowModepointerwaveripple
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.
gapoptionalnumberDistance between dots in px, default 24
radiusoptionalnumberReach of the glow in px, default 170
speedoptionalnumber1 is calm, 2 is twice as fast
clearCenteroptionalbooleanDims the glow behind the middle so a headline stays readable, default true
fadeoptional'edges' | 'none'How far the field fades out toward the edges
scrollLinkoptionalbooleanThe 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
'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.