dottedGlowBackground
A Backgrounds block/component from Aceternity UI — clean-room reimplemented for Domphy (see methodology). Call dottedGlowBackground() with no arguments for a working demo, or edit the code below live.
Implementation notes
Canvas particle-loop technique (same shape as this package's flickeringGrid): each dot's alpha oscillates continuously via sin(elapsedSeconds * randomSpeed + randomPhase) — radius stays fixed, only glow brightness pulses, matching the spec. Glow halo uses canvas shadowBlur/shadowColor. IntersectionObserver pauses the rAF loop off-screen; ResizeObserver recomputes the grid. Optional radial vignette is a CSS mask-image on the canvas rather than an extra draw pass. The spec's separate light-mode/dark-mode color pairs were intentionally NOT exposed as distinct props — Domphy's theme tokens (dotColor/glowColor as ThemeColor roles) already resolve differently per active theme automatically, so a manual light/dark pair would be redundant with the framework's own token model.
Status: ported · Reference: Aceternity UI original
// Aceternity UI "Dotted Glow Background" — clean-room reimplementation from
// the public behavior/visual spec only (no upstream source viewed or
// copied). A full-area grid of small dots where each dot continuously pulses
// its glow brightness at its own random pace, reading as a quiet, organic
// twinkling backdrop rather than a bold pattern.
//
// Canvas particle-loop technique, the same shape this package's
// `flickeringGrid` uses for its own twinkling grid: a flat array holds one
// `{ phase, speed }` pair per grid cell (assigned once, on layout), and every
// `requestAnimationFrame` tick recomputes each dot's alpha from
// `sin(elapsedSeconds * speed + phase)` — a smooth, continuous oscillation,
// not a random reroll — so dots glow up and down rather than flicker/twinkle
// on/off. Every dot gets its own randomized `speed` within the configured
// range, so they drift in and out of phase with no visible synchronized
// wave. The glow halo is drawn via canvas `shadowBlur`/`shadowColor` rather
// than a second, larger transparent circle. An `IntersectionObserver` pauses
// the loop while scrolled out of view and a `ResizeObserver` recomputes the
// grid on resize, matching `flickeringGrid`'s own lifecycle. The optional
// radial vignette is a CSS `mask-image` on the canvas itself (no extra
// canvas draw pass needed) — the same masking idiom this package's `ripple`
// uses for its own edge fade.
import type { DomphyElement, ElementNode, StyleObject } from "@domphy/core";
import { heading, paragraph } from "@domphy/ui";
import { type ThemeColor, themeColorToken, themeColor, themeSpacing } from "@domphy/theme";
export interface DottedGlowBackgroundProps {
/** Grid gap between dots, in canvas px. Defaults to `24`. */
spacing?: number;
/** Base dot radius, in canvas px. Defaults to `1.5`. */
dotRadius?: number;
/** Theme color family for the dot fill. Defaults to `"neutral"`. */
dotColor?: ThemeColor;
/** Theme color family for the glow halo. Defaults to `"primary"`. */
glowColor?: ThemeColor;
/** Overall layer opacity multiplier, 0–1. Defaults to `0.7`. */
layerOpacity?: number;
/** Radially fades dots out near the container's edges/corners instead of a hard cutoff. Defaults to `true`. */
vignette?: boolean;
/** Minimum per-dot pulse angular speed, in rad/s. Defaults to `0.4`. */
minPulseSpeed?: number;
/** Maximum per-dot pulse angular speed, in rad/s. Defaults to `1.3`. */
maxPulseSpeed?: number;
/** Global multiplier applied on top of every dot's own randomized speed. Defaults to `1`. */
speedMultiplier?: number;
/** Fixed canvas width, in px. Omit to fill the parent container's measured width. */
width?: number;
/** Fixed canvas height, in px. Omit to fill the parent container's measured height. */
height?: number;
/** Foreground content layered above the dot grid. Defaults to a small demo heading. */
children?: DomphyElement | DomphyElement[];
/** Passthrough style merged onto the outer container. */
style?: StyleObject;
}
interface GlowDot {
x: number;
y: number;
phase: number;
speed: number;
}
const MIN_ALPHA = 0.12;
const MAX_ALPHA = 0.9;
function computeDotGrid(containerWidth: number, containerHeight: number, spacing: number): GlowDot[] {
const columns = Math.max(1, Math.ceil(containerWidth / spacing) + 1);
const rows = Math.max(1, Math.ceil(containerHeight / spacing) + 1);
const dots: GlowDot[] = [];
for (let row = 0; row < rows; row += 1) {
for (let column = 0; column < columns; column += 1) {
dots.push({
x: column * spacing,
y: row * spacing,
phase: Math.random() * Math.PI * 2,
speed: 0, // assigned by the caller once min/max/multiplier are known
});
}
}
return dots;
}
/**
* A full-area grid of small dots, each continuously pulsing its own glow
* brightness at its own randomized pace — an ambient, organic twinkling
* backdrop. Call with no arguments for a working demo — a dark panel with a
* quietly shimmering dot grid behind a heading.
*/
function dottedGlowBackground(props: DottedGlowBackgroundProps = {}): DomphyElement<"div"> {
const spacing = Math.max(4, props.spacing ?? 24);
const dotRadius = Math.max(0.5, props.dotRadius ?? 1.5);
const dotColor = props.dotColor ?? "neutral";
const glowColor = props.glowColor ?? "primary";
const layerOpacity = props.layerOpacity ?? 0.7;
const vignette = props.vignette ?? true;
const minPulseSpeed = Math.max(0.05, props.minPulseSpeed ?? 0.4);
const maxPulseSpeed = Math.max(minPulseSpeed, props.maxPulseSpeed ?? 1.3);
const speedMultiplier = props.speedMultiplier ?? 1;
const fixedWidth = props.width;
const fixedHeight = props.height;
const contentChildren: DomphyElement[] = props.children
? Array.isArray(props.children)
? props.children
: [props.children]
: [
{ h2: "Dotted Glow Background", $: [heading()] } as DomphyElement,
{
p: "Every dot pulses its own glow on an independent, randomized cadence.",
$: [paragraph()],
} as DomphyElement,
];
// `_doctorDisable` is a doctor-only annotation not present in core's strict
// `PartialElement` type — build through an untyped literal, then assert, so
// the excess-property check doesn't fire (mirrors flickeringGrid.ts).
const canvasElement = {
canvas: null,
ariaHidden: "true",
// Decorative canvas with no text of its own — fill/glow colors are
// resolved imperatively below (canvas 2D has no themeColor() var() concept).
_doctorDisable: "missing-color",
style: {
position: "absolute",
inset: 0,
width: fixedWidth ? `${fixedWidth}px` : "100%",
height: fixedHeight ? `${fixedHeight}px` : "100%",
pointerEvents: "none",
maskImage: vignette
? "radial-gradient(ellipse at center, black 45%, transparent 100%)"
: undefined,
WebkitMaskImage: vignette
? "radial-gradient(ellipse at center, black 45%, transparent 100%)"
: undefined,
},
_onMount: (node: ElementNode) => {
const canvas = node.domElement as HTMLCanvasElement | null;
const containerElement = canvas?.parentElement ?? null;
if (!canvas || !containerElement || typeof window === "undefined") return;
// Headless/test runtimes without a real 2D canvas backend resolve
// `getContext` to `null` rather than throwing — bail before starting.
const context = canvas.getContext("2d");
if (!context) return;
const devicePixelRatio = Math.min(window.devicePixelRatio || 1, 2);
let cssWidth = 0;
let cssHeight = 0;
let dots: GlowDot[] = [];
let animationFrameId: number | null = null;
let resizeObserver: ResizeObserver | null = null;
let intersectionObserver: IntersectionObserver | null = null;
const startTime = performance.now();
const dotFillColor = (() => {
try {
return themeColorToken(node, "shift-9", dotColor);
} catch {
return "#aaaaaa";
}
})();
const glowShadowColor = (() => {
try {
return themeColorToken(node, "shift-9", glowColor);
} catch {
return "#7aa2ff";
}
})();
function resizeCanvas(): void {
const rect = containerElement!.getBoundingClientRect();
cssWidth = fixedWidth ?? rect.width;
cssHeight = fixedHeight ?? rect.height;
canvas!.width = Math.max(1, Math.floor(cssWidth * devicePixelRatio));
canvas!.height = Math.max(1, Math.floor(cssHeight * devicePixelRatio));
// `setTransform` (not `scale`) so repeated resizes never compound the
// device-pixel-ratio scale factor onto itself.
context!.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
dots = computeDotGrid(cssWidth, cssHeight, spacing).map((dot) => ({
...dot,
speed: (minPulseSpeed + Math.random() * (maxPulseSpeed - minPulseSpeed)) * speedMultiplier,
}));
}
function drawFrame(elapsedSeconds: number): void {
context!.clearRect(0, 0, cssWidth, cssHeight);
for (const dot of dots) {
const oscillation = (Math.sin(elapsedSeconds * dot.speed + dot.phase) + 1) / 2;
const alpha = (MIN_ALPHA + oscillation * (MAX_ALPHA - MIN_ALPHA)) * layerOpacity;
context!.globalAlpha = Math.max(0, Math.min(1, alpha));
context!.shadowBlur = dotRadius * 4;
context!.shadowColor = glowShadowColor;
context!.fillStyle = dotFillColor;
context!.beginPath();
context!.arc(dot.x, dot.y, dotRadius, 0, Math.PI * 2);
context!.fill();
}
context!.globalAlpha = 1;
context!.shadowBlur = 0;
}
function tick(time: number): void {
// Belt-and-suspenders stop condition: some hosts (e.g. a test harness
// that wipes the DOM directly instead of going through the framework's
// removal lifecycle) never fire the "Remove" hook below, and this loop
// has no other convergence condition — it reschedules unconditionally
// every frame. Bailing here once the canvas is detached prevents it
// from leaking forever.
if (!canvas!.isConnected) return;
drawFrame((time - startTime) / 1000);
animationFrameId = window.requestAnimationFrame(tick);
}
function startLoop(): void {
if (animationFrameId !== null) return;
animationFrameId = window.requestAnimationFrame(tick);
}
function stopLoop(): void {
if (animationFrameId === null) return;
window.cancelAnimationFrame(animationFrameId);
animationFrameId = null;
}
resizeCanvas();
drawFrame(0);
if (typeof ResizeObserver !== "undefined") {
resizeObserver = new ResizeObserver(() => {
resizeCanvas();
drawFrame((performance.now() - startTime) / 1000);
});
resizeObserver.observe(containerElement);
}
if (typeof IntersectionObserver === "function") {
intersectionObserver = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) startLoop();
else stopLoop();
}
});
intersectionObserver.observe(containerElement);
} else {
// No IntersectionObserver support — fail open and animate always.
startLoop();
}
node.addHook("Remove", () => {
stopLoop();
resizeObserver?.disconnect();
intersectionObserver?.disconnect();
});
},
} as DomphyElement<"canvas">;
return {
div: [
canvasElement,
{ div: contentChildren, style: { position: "relative", zIndex: 1 } },
],
dataTone: "shift-16",
style: {
position: "relative",
overflow: "hidden",
borderRadius: themeSpacing(4),
padding: themeSpacing(8),
minHeight: themeSpacing(64),
backgroundColor: (listener) => themeColor(listener, "inherit"),
color: (listener) => themeColor(listener, "shift-9"),
...(props.style ?? {}),
} as StyleObject,
};
}
export { dottedGlowBackground };