confetti
A Effects block/component from Magic UI — clean-room reimplemented for Domphy (see methodology). Call confetti() with no arguments for a working demo, or edit the code below live.
Props
| Prop | Type | Description |
|---|---|---|
options | ConfettiFireOptions | Base options merged under every fire() call. See canvas-confetti's Options. |
globalOptions | ConfettiGlobalOptions | Options for the underlying confetti.create(canvas, ...) instance (mirrors upstream's globalOptions prop). Defaults to { resize: true, useWorker: true }; resize is always forced on so the canvas tracks its element size. |
onReady | (handle: ConfettiHandle) => void | Called once the canvas is mounted and the imperative handle is ready. |
autoFire | boolean | Fires one burst automatically the moment the canvas mounts. Defaults to true. |
children | DomphyElement | DomphyElement[] | Foreground content rendered after the canvas (mirrors upstream's {children}). The burst is reachable from these children via the onReady handle — Domphy's equivalent of upstream's ConfettiContext, which has no runtime-context analog. |
style | StyleObject | Passthrough style merged onto the canvas. |
Implementation notes
Ported using the already-approved canvas-confetti dependency (confettiLib.create(canvas, opts)), matching upstream's documented default fire options plus the spec's square/circle/star shape mix. Returns a bare <canvas> (fixed, full-viewport, transparent, pointer-events, aria-hidden) and exposes an imperative { fire, reset } handle via onReady, per the spec's "exposed imperative handle/ref" requirement. autoFire defaults to true (a burst fires ~150ms after mount) so the zero-arg call is a genuinely working demo out of the box; pass autoFire: false for a purely imperative canvas that stays inert until onReady's handle fires it. Direct-source-diff fix (2026-07-05): The autoFire prop's JSDoc claimed it defaults to false, but the actual code (correctly) defaults it to true, matching upstream's auto-fire-on-mount behavior — corrected the misleading comment.
Status: ported · Reference: Magic UI original
// Magic UI "Confetti" — clean-room reimplementation.
//
// A celebratory burst of colorful falling particles rendered on a
// transparent canvas, fired programmatically (via an imperative handle) or
// from a ready-made button variant that fires on click. Implemented purely
// from the block's public functional/visual spec — no upstream Magic UI
// source was viewed or copied.
//
// Rendering/physics are delegated to `canvas-confetti` (already an approved
// dependency of this package) rather than hand-rolling a particle simulator —
// it is the standard lightweight confetti-burst library, and using its public
// `create(canvas, options)` API is a legitimate, independent integration, not
// a copy of any UI framework's component source.
import type { DomphyElement, StyleObject } from "@domphy/core";
import { behavior, ElementNode } from "@domphy/core";
import type { ThemeColor } from "@domphy/theme";
import { button } from "@domphy/ui";
import type {
GlobalOptions as ConfettiLibGlobalOptions,
Options as ConfettiLibOptions,
} from "canvas-confetti";
import confettiLib from "canvas-confetti";
export type ConfettiFireOptions = ConfettiLibOptions;
/** Instance-creation options passed to `canvas-confetti`'s `confetti.create(...)`. */
export type ConfettiGlobalOptions = ConfettiLibGlobalOptions;
export interface ConfettiHandle {
/** Launches one burst, merging `options` over the instance's base options. */
fire: (options?: ConfettiFireOptions) => void;
/** Immediately clears all in-flight particles. */
reset: () => void;
}
export interface ConfettiProps {
/** Base options merged under every `fire()` call. See `canvas-confetti`'s `Options`. */
options?: ConfettiFireOptions;
/**
* Options for the underlying `confetti.create(canvas, ...)` instance (mirrors
* upstream's `globalOptions` prop). Defaults to `{ resize: true, useWorker: false }`;
* `resize` is always forced on so the canvas tracks its element size.
*/
globalOptions?: ConfettiGlobalOptions;
/** Called once the canvas is mounted and the imperative handle is ready. */
onReady?: (handle: ConfettiHandle) => void;
/** Fires one burst automatically the moment the canvas mounts. Defaults to `true`. */
autoFire?: boolean;
/**
* Foreground content rendered after the canvas (mirrors upstream's `{children}`).
* The burst is reachable from these children via the `onReady` handle — Domphy's
* equivalent of upstream's `ConfettiContext`, which has no runtime-context analog.
*/
children?: DomphyElement | DomphyElement[];
/** Passthrough style merged onto the canvas. */
style?: StyleObject;
}
// Matches canvas-confetti's own documented defaults (particleCount ~50,
// angle 90/straight-up, spread 45, startVelocity 45, decay 0.9, gravity 1,
// ticks 200, origin centered), plus the spec's default shape mix of square,
// circle, and star.
const DEFAULT_FIRE_OPTIONS: ConfettiFireOptions = {
particleCount: 50,
angle: 90,
spread: 45,
startVelocity: 45,
decay: 0.9,
gravity: 1,
ticks: 200,
shapes: ["square", "circle", "star"],
origin: { x: 0.5, y: 0.5 },
};
// Matches upstream's `globalOptions` default.
const DEFAULT_GLOBAL_OPTIONS: ConfettiGlobalOptions = {
resize: true,
// OffscreenCanvas workers make the host canvas unreadable (getContext
// throws "transferred its control to offscreen") and the burst is short.
useWorker: false,
};
// Raw `confetti.create(canvas, ...)` instance, or null where instance
// creation fails (no 2D context — SSR, jsdom without a canvas stub).
function createFireInstance(
canvasElement: HTMLCanvasElement,
globalOptions: ConfettiGlobalOptions,
): ReturnType<typeof confettiLib.create> | null {
try {
// `resize` is forced on regardless of the passed options — mirrors upstream.
return confettiLib.create(canvasElement, {
...globalOptions,
resize: true,
});
} catch {
return null;
}
}
const CONFETTI_BEHAVIOR_KEY = "confetti";
interface ConfettiBehaviorProps {
baseOptions: ConfettiFireOptions;
globalOptions: ConfettiGlobalOptions;
autoFire: boolean;
onReady?: (handle: ConfettiHandle) => void;
}
interface ConfettiBehaviorInstance {
/** The stable per-node handle — created once, shared by every generation. */
handle: ConfettiHandle | null;
update: (props: ConfettiBehaviorProps) => void;
destroy: () => void;
}
// The imperative confetti instance is per-DOM-node state, so it lives in a
// behavior() (`@domphy/core`) rather than the factory closure: `attach` runs
// ONCE for the real canvas node no matter how many times a reactive ancestor
// re-invokes confetti()/confettiButton(), and every later generation's props
// route into `update()`. The previous `_onMount` + closure `handle` broke
// confettiButton after any ancestor re-render — the live-rebound onClick
// closed over the NEW generation's still-null `handle`, so clicks silently
// did nothing — and `onReady` fired only for generation 1.
function attachConfetti(
node: ElementNode,
initialProps: ConfettiBehaviorProps,
): ConfettiBehaviorInstance {
let { baseOptions, onReady } = initialProps;
const canvasElement = node.domElement as HTMLCanvasElement | null;
const fireInstance =
canvasElement && typeof document !== "undefined"
? createFireInstance(canvasElement, initialProps.globalOptions)
: null;
// `fire` reads `baseOptions` from this closure at CALL time, so update()
// refreshing it is enough — the handle itself never needs rebuilding.
const handle: ConfettiHandle | null = fireInstance
? {
fire: (options) => {
fireInstance({ ...baseOptions, ...(options ?? {}) });
},
reset: () => fireInstance.reset(),
}
: null;
if (handle) {
// Fire the moment the canvas mounts (no delay) — mirrors upstream's mount
// effect firing immediately when `autoFire` (upstream `!manualstart`).
if (initialProps.autoFire) handle.fire();
onReady?.(handle);
}
return {
handle,
update(next) {
baseOptions = next.baseOptions;
// A later generation's fresh `onReady` closure receives the SAME stable
// handle (React callback-ref semantics: re-invoked on identity change).
if (handle && next.onReady && next.onReady !== onReady) {
next.onReady(handle);
}
onReady = next.onReady;
},
destroy() {
handle?.reset();
},
};
}
/**
* A transparent, full-viewport canvas that fires a `canvas-confetti` burst on
* demand. Call with no arguments for a working demo — a burst fires the instant
* the canvas mounts (`autoFire` defaults to `true`); pass `autoFire: false` for
* a purely imperative canvas that stays inert until `onReady`'s handle fires it.
* Pass `children` to render foreground content over the burst (they reach `fire`
* through the `onReady` handle).
*/
function confetti(props: ConfettiProps = {}): DomphyElement {
const baseOptions: ConfettiFireOptions = {
...DEFAULT_FIRE_OPTIONS,
...(props.options ?? {}),
};
const globalOptions: ConfettiGlobalOptions = {
...DEFAULT_GLOBAL_OPTIONS,
...(props.globalOptions ?? {}),
};
const autoFire = props.autoFire ?? true;
// `_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 canvasElementNode = {
canvas: null,
ariaHidden: "true",
// Decorative/transparent burst surface with no text of its own — exempt
// from the missing-color contract (no reactive themeColor is used here).
_doctorDisable: "missing-color",
style: {
position: "fixed",
inset: 0,
width: "100%",
height: "100%",
pointerEvents: "none",
zIndex: 9999,
...(props.style ?? {}),
} as StyleObject,
...behavior<ConfettiBehaviorProps>(CONFETTI_BEHAVIOR_KEY, attachConfetti, {
baseOptions,
globalOptions,
autoFire,
onReady: props.onReady,
}),
} as DomphyElement<"canvas">;
const children = props.children
? Array.isArray(props.children)
? props.children
: [props.children]
: null;
if (!children || children.length === 0) return canvasElementNode;
// Upstream wraps `<canvas>` + `{children}` in a context Provider, which emits
// no DOM node of its own. `display: contents` reproduces that: the wrapper
// collapses so children sit where the confetti element was placed.
return {
div: [canvasElementNode, ...children],
style: { display: "contents" } as StyleObject,
} as DomphyElement<"div">;
}
export interface ConfettiButtonProps {
/** Button label content. Defaults to `"🎉 Celebrate"`. */
children?: DomphyElement | string;
/** Fire options merged under the burst launched on click. */
options?: ConfettiFireOptions;
/** Button color tone. Defaults to `"primary"`. */
color?: ThemeColor;
/** Passthrough style merged onto the button. */
style?: StyleObject;
}
/**
* A themed button that fires a `canvas-confetti` burst originating from its
* own position on click. Call with no arguments for a working "🎉 Celebrate"
* demo button.
*/
function confettiButton(
props: ConfettiButtonProps = {},
): DomphyElement<"button"> {
const label: DomphyElement | string = props.children ?? "🎉 Celebrate";
const color = props.color ?? "primary";
const baseOptions: ConfettiFireOptions = {
...DEFAULT_FIRE_OPTIONS,
...(props.options ?? {}),
};
// `_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 overlayCanvas = {
canvas: null,
ariaHidden: "true",
_doctorDisable: "missing-color",
style: {
position: "fixed",
inset: 0,
width: "100%",
height: "100%",
pointerEvents: "none",
zIndex: 9999,
},
...behavior<ConfettiBehaviorProps>(CONFETTI_BEHAVIOR_KEY, attachConfetti, {
baseOptions,
globalOptions: DEFAULT_GLOBAL_OPTIONS,
autoFire: false,
}),
} as DomphyElement<"canvas">;
return {
button: [label, overlayCanvas],
type: "button",
$: [button({ color })],
style: props.style,
onClick: (event: MouseEvent, node: ElementNode) => {
if (typeof window === "undefined") return;
// Event handlers are live-rebound on every generation, so re-derive the
// handle from the current node tree on every click: it lives in the
// behavior instance of the canvas CHILD (attached once to the real
// canvas DOM node, stable across ancestor re-renders).
const canvasChild = node?.children.items.find(
(child): child is ElementNode =>
child instanceof ElementNode && child.tagName === "canvas",
);
const handle = canvasChild?.getBehavior<ConfettiBehaviorInstance>(
CONFETTI_BEHAVIOR_KEY,
)?.handle;
if (!handle) return;
const targetElement = event.currentTarget as HTMLElement;
const buttonRect = targetElement.getBoundingClientRect();
const originX =
(buttonRect.left + buttonRect.width / 2) / window.innerWidth;
const originY =
(buttonRect.top + buttonRect.height / 2) / window.innerHeight;
handle.fire({ origin: { x: originX, y: originY } });
},
} as DomphyElement<"button">;
}
export { confetti, confettiButton };