Domphy

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.

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. One deliberate default-behavior choice: autoFire defaults to false (the canvas stays inert until onReady's handle fires it, or autoFire: true is passed) rather than bursting automatically on mount — this matches the primitive's real-world purely-imperative usage and avoids touching the 2D canvas context in environments without a real canvas backend (verified: canvas-confetti's .create() never touches getContext synchronously, so this is a design choice, not a technical limitation).

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, ElementNode, StyleObject } from "@domphy/core";
import { button } from "@domphy/ui";
import type { ThemeColor } from "@domphy/theme";
import confettiLib from "canvas-confetti";
import type { Options as ConfettiLibOptions } from "canvas-confetti";

export type ConfettiFireOptions = ConfettiLibOptions;

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;
  /** Called once the canvas is mounted and the imperative handle is ready. */
  onReady?: (handle: ConfettiHandle) => void;
  /** Fires one burst automatically shortly after mount. Defaults to `false`. */
  autoFire?: boolean;
  /** Delay (ms) before the automatic burst. Defaults to `150`. */
  autoFireDelay?: number;
  /** 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 },
};

function createConfettiHandle(
  canvasElement: HTMLCanvasElement,
  baseOptions: ConfettiFireOptions,
): ConfettiHandle | null {
  let instanceFire: ReturnType<typeof confettiLib.create> | null = null;
  try {
    instanceFire = confettiLib.create(canvasElement, { resize: true, useWorker: false });
  } catch {
    instanceFire = null;
  }
  if (!instanceFire) return null;

  const fire = instanceFire;
  return {
    fire: (options) => {
      fire({ ...baseOptions, ...(options ?? {}) });
    },
    reset: () => fire.reset(),
  };
}

/**
 * A transparent, full-viewport canvas that fires a `canvas-confetti` burst on
 * demand. Call with no arguments for a working demo — the canvas is inert
 * until told to fire (via `onReady`'s handle, or `autoFire: true`), which
 * matches the primitive's purely imperative real-world usage.
 */
function confetti(props: ConfettiProps = {}): DomphyElement<"canvas"> {
  const baseOptions: ConfettiFireOptions = { ...DEFAULT_FIRE_OPTIONS, ...(props.options ?? {}) };
  const autoFire = props.autoFire ?? false;
  const autoFireDelay = props.autoFireDelay ?? 150;

  // `_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 element = {
    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,
    _onMount: (node: ElementNode) => {
      const canvasElement = node.domElement as HTMLCanvasElement | null;
      if (!canvasElement || typeof document === "undefined") return;

      const handle = createConfettiHandle(canvasElement, baseOptions);
      if (!handle) return;

      let autoFireTimer: ReturnType<typeof setTimeout> | null = null;
      if (autoFire) {
        autoFireTimer = setTimeout(() => handle.fire(), autoFireDelay);
      }

      props.onReady?.(handle);

      node.addHook("Remove", () => {
        if (autoFireTimer) clearTimeout(autoFireTimer);
        handle.reset();
      });
    },
  };
  return element as DomphyElement<"canvas">;
}

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 ?? {}) };

  let handle: ConfettiHandle | null = null;

  // `_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,
    },
    _onMount: (node: ElementNode) => {
      const canvasElement = node.domElement as HTMLCanvasElement | null;
      if (!canvasElement || typeof document === "undefined") return;
      handle = createConfettiHandle(canvasElement, baseOptions);
      node.addHook("Remove", () => {
        handle?.reset();
        handle = null;
      });
    },
  } as DomphyElement<"canvas">;

  return {
    button: [label, overlayCanvas],
    type: "button",
    $: [button({ color })],
    style: props.style,
    onClick: (event: MouseEvent) => {
      if (!handle || typeof window === "undefined") 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 };

← Back to Magic UI catalog