particles
A Effects block/component from Magic UI — clean-room reimplemented for Domphy (see methodology). Call particles() with no arguments for a working demo, or edit the code below live.
Implementation notes
Fully functional canvas particle simulation (ambient drift + edge wrap + mouse-repulsion easing scaled by ease/staticity, devicePixelRatio-aware canvas sizing, ResizeObserver regeneration, refreshKey: ValueOrState<unknown> for external regeneration triggers). One noted gap: canvas 2D fill color is resolved ONCE at mount via themeColorToken() (a concrete hex string), not reactively via themeColor()'s var(--…) CSS reference, because canvas drawing calls are imperative JS with no concept of CSS custom properties — so the particle color will NOT automatically repaint if the page's theme is swapped at runtime after mount (a live-swap would need an explicit theme-change listener re-deriving the token and letting the next animation frame pick it up; out of scope for this decorative background primitive, and consistent with the spec's own note that upstream demos typically resolve theme-based color once at setup rather than reactively). Guards canvas.getContext('2d') === null (verified: jsdom without the optional canvas npm package returns null, not a throw) before starting the animation loop, so it degrades gracefully in non-canvas-capable/headless environments instead of crashing.
Status: ported · Reference: Magic UI original
// Magic UI "Particles" — clean-room reimplementation.
//
// An ambient animated dot-field background rendered on canvas: many tiny
// particles drift slowly and scatter away from the mouse cursor, typically
// layered behind hero text for depth and subtle motion. Implemented purely
// from the block's public functional/visual spec — no upstream Magic UI
// source was viewed or copied.
//
// Canvas particle simulation via `requestAnimationFrame`: particles are
// generated on mount (and regenerated on resize/refresh) with random
// position/size/drift, then each frame drifts by its own ambient velocity,
// wraps at the container edges, and eases toward a mouse-repulsion offset
// when the pointer is within an interaction radius (scaled by `staticity`,
// approached at a rate scaled by `ease`). Mouse position is tracked via
// pointermove on the container (the canvas itself has `pointerEvents: none`
// so it never intercepts clicks meant for foreground content) and the canvas
// backing store is scaled for `devicePixelRatio` for crisp rendering.
//
// Canvas fill color is resolved once, at mount, from the current theme via
// `themeColorToken` (which returns a concrete hex string — canvas 2D has no
// concept of CSS custom properties, so `themeColor()`'s `var(--…)` reference
// cannot be used here). It does not re-resolve on a later runtime theme
// swap; see this component's `fidelityNotes`.
import type { DomphyElement, ElementNode, StyleObject, ValueOrState } from "@domphy/core";
import { toState } from "@domphy/core";
import { heading, paragraph } from "@domphy/ui";
import { type ThemeColor, themeColor, themeColorToken, themeSpacing } from "@domphy/theme";
export interface ParticlesProps {
/** Number of particles. Defaults to `100`. */
quantity?: number;
/** Theme color family for the particles. Defaults to `"neutral"` (reads bright against a dark surface). */
color?: ThemeColor;
/** Base particle radius, in canvas pixels. Defaults to `1.2`. */
size?: number;
/** Higher = smoother/slower easing back to rest. Defaults to `50`. */
ease?: number;
/** Higher = more resistant to the mouse-repulsion force. Defaults to `50`. */
staticity?: number;
/** Ambient horizontal drift velocity. Defaults to `0`. */
vx?: number;
/** Ambient vertical drift velocity. Defaults to `0`. */
vy?: number;
/** Toggle (any `ValueOrState` value change) to regenerate the particle set. */
refreshKey?: ValueOrState<unknown>;
/** Foreground content layered above the particle field. Defaults to a small demo heading. */
children?: DomphyElement | DomphyElement[];
/** Passthrough style merged onto the outer container. */
style?: StyleObject;
}
interface ParticleInstance {
x: number;
y: number;
translateX: number;
translateY: number;
size: number;
alpha: number;
targetAlpha: number;
driftX: number;
driftY: number;
magnetism: number;
}
function hexTokenToRgba(hexToken: string, alpha: number): string {
const hex = hexToken.replace("#", "");
const isShort = hex.length === 3;
const red = parseInt(isShort ? hex[0] + hex[0] : hex.slice(0, 2), 16) || 255;
const green = parseInt(isShort ? hex[1] + hex[1] : hex.slice(2, 4), 16) || 255;
const blue = parseInt(isShort ? hex[2] + hex[2] : hex.slice(4, 6), 16) || 255;
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
}
function createParticle(
width: number,
height: number,
baseSize: number,
vx: number,
vy: number,
): ParticleInstance {
return {
x: Math.random() * width,
y: Math.random() * height,
translateX: 0,
translateY: 0,
size: baseSize + Math.random() * baseSize,
alpha: 0,
targetAlpha: Math.random() * 0.6 + 0.15,
driftX: (Math.random() - 0.5) * 0.15 + vx,
driftY: (Math.random() - 0.5) * 0.15 + vy,
magnetism: 0.15 + Math.random() * 0.6,
};
}
const MOUSE_INTERACTION_RADIUS = 120;
/**
* An ambient animated dot-field background (canvas), where particles drift
* slowly and scatter away from the mouse cursor. Call with no arguments for a
* working demo — a dark panel with 100 drifting particles behind a heading.
*/
function particles(props: ParticlesProps = {}): DomphyElement<"div"> {
const quantity = Math.max(1, Math.round(props.quantity ?? 100));
const color = props.color ?? "neutral";
const baseSize = props.size ?? 1.2;
const ease = Math.max(1, props.ease ?? 50);
const staticity = Math.max(1, props.staticity ?? 50);
const vx = props.vx ?? 0;
const vy = props.vy ?? 0;
const refreshState = toState(props.refreshKey ?? null, "refreshKey");
const contentChildren: DomphyElement[] = props.children
? Array.isArray(props.children)
? props.children
: [props.children]
: [
{ h2: "Particles", $: [heading()] } as DomphyElement,
{
p: "A quiet field of drifting particles that scatter from the cursor.",
$: [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 fadeOverlay() in the
// marquee block).
const canvasElement = {
canvas: null,
ariaHidden: "true",
// Decorative canvas with no text of its own — exempt from the
// missing-color contract (there is no reactive themeColor on this element
// at all; fill color is imperative canvas state, resolved below).
_doctorDisable: "missing-color",
style: {
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
pointerEvents: "none",
},
_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 (e.g. jsdom
// without the optional `canvas` npm package) resolve `getContext` to
// `null` rather than throwing — bail out before starting the loop.
const context = canvas.getContext("2d");
if (!context) return;
let particleList: ParticleInstance[] = [];
let devicePixelRatio = Math.min(window.devicePixelRatio || 1, 2);
let canvasWidth = 0;
let canvasHeight = 0;
let mouseX = -MOUSE_INTERACTION_RADIUS * 2;
let mouseY = -MOUSE_INTERACTION_RADIUS * 2;
let pointerActive = false;
let animationFrameId: number | null = null;
let resizeObserver: ResizeObserver | null = null;
// shift-11 (not a small shift-1) so particles read as a bright,
// clearly visible dot field against the container's own dark surface —
// a small shift only nudges a couple of ramp steps toward the opposite
// edge and would barely be distinguishable from the background.
const fillColor = (() => {
try {
return themeColorToken(node, "shift-11", color);
} catch {
return "#ffffff";
}
})();
function resizeCanvas(): void {
const rect = containerElement!.getBoundingClientRect();
canvasWidth = rect.width;
canvasHeight = rect.height;
canvas!.width = Math.max(1, Math.floor(canvasWidth * devicePixelRatio));
canvas!.height = Math.max(1, Math.floor(canvasHeight * devicePixelRatio));
context!.scale(devicePixelRatio, devicePixelRatio);
}
function generateParticles(): void {
particleList = Array.from({ length: quantity }, () =>
createParticle(canvasWidth, canvasHeight, baseSize, vx, vy),
);
}
function respawnParticle(particle: ParticleInstance): void {
const respawned = createParticle(canvasWidth, canvasHeight, baseSize, vx, vy);
Object.assign(particle, respawned);
}
function tick(): void {
// Belt-and-suspenders: bail without rescheduling once the canvas is
// no longer in the document, so this loop can't outlive the
// component even if the framework's own "Remove" hook never fires
// (e.g. a host that wipes the DOM directly instead of going through
// node removal).
if (!canvas!.isConnected) return;
context!.clearRect(0, 0, canvasWidth, canvasHeight);
for (const particle of particleList) {
particle.x += particle.driftX;
particle.y += particle.driftY;
if (
particle.x < -particle.size ||
particle.x > canvasWidth + particle.size ||
particle.y < -particle.size ||
particle.y > canvasHeight + particle.size
) {
respawnParticle(particle);
}
particle.alpha += (particle.targetAlpha - particle.alpha) * 0.05;
let targetTranslateX = 0;
let targetTranslateY = 0;
if (pointerActive) {
const distanceX = mouseX - particle.x;
const distanceY = mouseY - particle.y;
const distanceSquared = distanceX * distanceX + distanceY * distanceY;
if (distanceSquared < MOUSE_INTERACTION_RADIUS * MOUSE_INTERACTION_RADIUS) {
targetTranslateX = -(distanceX / staticity) * particle.magnetism;
targetTranslateY = -(distanceY / staticity) * particle.magnetism;
}
}
particle.translateX += (targetTranslateX - particle.translateX) / ease;
particle.translateY += (targetTranslateY - particle.translateY) / ease;
context!.beginPath();
context!.arc(
particle.x + particle.translateX,
particle.y + particle.translateY,
particle.size,
0,
Math.PI * 2,
);
context!.fillStyle = hexTokenToRgba(fillColor, particle.alpha);
context!.fill();
}
animationFrameId = window.requestAnimationFrame(tick);
}
function handlePointerMove(event: PointerEvent): void {
const rect = containerElement!.getBoundingClientRect();
mouseX = event.clientX - rect.left;
mouseY = event.clientY - rect.top;
pointerActive = true;
}
function handlePointerLeave(): void {
pointerActive = false;
}
resizeCanvas();
generateParticles();
animationFrameId = window.requestAnimationFrame(tick);
containerElement.addEventListener("pointermove", handlePointerMove);
containerElement.addEventListener("pointerleave", handlePointerLeave);
if (typeof ResizeObserver !== "undefined") {
resizeObserver = new ResizeObserver(() => {
resizeCanvas();
generateParticles();
});
resizeObserver.observe(containerElement);
}
const releaseRefreshListener = refreshState.addListener(() => {
resizeCanvas();
generateParticles();
});
node.addHook("Remove", () => {
if (animationFrameId !== null) window.cancelAnimationFrame(animationFrameId);
resizeObserver?.disconnect();
containerElement.removeEventListener("pointermove", handlePointerMove);
containerElement.removeEventListener("pointerleave", handlePointerLeave);
releaseRefreshListener();
});
},
} as DomphyElement<"canvas">;
return {
div: [
canvasElement,
{ div: contentChildren, style: { position: "relative", zIndex: 1 } },
],
dataTone: "shift-15",
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 { particles };