# Confetti (Moonarc)

Fires a burst of paper confetti on load, on a click or on an ma:confetti event. One fixed canvas exists only while pieces are falling. Gravity, drag, launch speed and spread are custom properties declared per preset in the stylesheet: the script reads them and carries no constant. Also callable as confetti() from your own handler. Never fires under reduced motion.

- Import: `import Confetti from '@moonarc/core/Confetti'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/confetti.json`
- Tier C · category pointer · trigger load, click
- Readout: `<Confetti trigger="click" for="#celebrate">`
- Browser support: widely (every browser)
- Measured cost: 2.4 kB raw JS · 1.3 kB gzip · + runtime + canvas (with dependencies 5.8 kB raw; CSS 4.7 kB raw)
- Page: https://moonarc.dev/components/confetti/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `trigger` | `'load' | 'click' | 'event'` | `'click'` | load: once when mounted, once per element (one kept across a navigation by transition:persist does not burst again) · click: on every click of `for`, or of the parent when `for` is not given · event: on every `ma:confetti` dispatched on document whose `detail.target` is empty or this element, and once at bind when the element carries `data-ma-confetti-fire` (removed as it fires). A script that may run before the component sets the attribute and dispatches in the same task: whichever runs first, one burst. |
| `for` | `string` | none | Selector of the element whose clicks fire (click); on every trigger the burst starts from its centre. |
| `count` | `number` | `120` | Pieces per burst. |
| `colors` | `string[]` | `five accents from the stylesheet` | Literal colours (hex, rgb(), oklch()), cycled through; var() is not resolved by the canvas. For tokens, use CSS on .ma-confetti: its computed `color` and `border-top/right/bottom/left-color` are the five the default reads. |
| `origin` | `{ x, y }` | none | Launch point in viewport px; default the centre of `for` (or of the clicked parent), the viewport centre on load or event without `for`. |
| `angle` | `number` | `0` | Launch direction in degrees: 0 up, 90 right. |
| `spread` | `number` | `the preset token` | Launch cone in degrees; the token is 70, narrower on snap, wider on lively. |

## Usage

```astro
<button id="celebrate" type="button">Celebrate</button>
<Confetti trigger="click" for="#celebrate" />

<!-- tokens are CSS on the element; the canvas cannot resolve var() in `colors` -->
<Confetti trigger="click" for="#celebrate" style="color: var(--primary, #4c7dff); border-color: var(--chart-1, #e4572e) var(--chart-2, #3a9a5b) var(--chart-3, #f5a524) var(--chart-4, #c94ad3)" />

<!-- from any script, without importing the lib; the attribute covers a script that runs before the component binds -->
<Confetti trigger="event" id="party" />
<script>
  const party = document.getElementById('party');
  party.setAttribute('data-ma-confetti-fire', '');
  document.dispatchEvent(new CustomEvent('ma:confetti', { detail: { target: party } }));
</script>

<!-- or the function itself -->
<script>
  import { confetti } from '@moonarc/core/confetti';
  form.addEventListener('submit', () => confetti({ count: 200 }));
</script>
```

## Reduced motion

Nothing fires on load, on click or on an event, and a `data-ma-confetti-fire` request is discarded. The burst itself reads the setting live and every trigger stays armed, so the site's motion switch applies to the next one in both directions, without a reload.

## With ClientRouter

Bound through the shared runtime, the ma:confetti listener included (one per element, removed on swap); a burst in flight is dropped with its canvas before the swap, and the next page starts clean.

## Craft

- The canvas is created on the first piece and removed after the last one lands: a page with a confetti button costs a hidden span until it is pressed.
- The physics live in CSS as custom properties per preset (snap launches harder and stops sooner, ambient floats) so the burst has the page's character; the script only reads numbers.
- Pieces are paper rectangles that rotate and flutter (a cosine on their height); drag on the velocity gives the arc its shoulder.
- Bursts share one canvas and one frame loop; the loop sleeps in a hidden tab like every canvas in the library.
- confetti() is exported like toast(): the component is the declarative wiring, the function is the act.
- trigger="event" is the ma:toast pattern for code that should not import the lib, made independent of script order: a request made before the component binds is an attribute it consumes, one made after is the event, and firing clears the attribute, so set-then-dispatch bursts once.
- Colours are read from the element's computed style, like every canvas in the library: fillStyle does not resolve var(), so a token passed through `colors` paints black, while the same token on `color` or a border colour arrives resolved and follows the theme.

## Replaces

- Confetti (Magic UI)
- canvas-confetti
- react-confetti

## Source

```astro
---
/**
 * Confetti — a burst of paper on load, on a click or on an `ma:confetti`
 * event, then nothing: the canvas exists only while pieces are falling and is
 * removed after the last one. The physics (gravity, drag, launch speed,
 * spread, flutter) are custom properties this stylesheet declares on :root,
 * one set per preset, so a snappy page bursts harder than a gentle one; the
 * script reads them and never carries a constant of its own. Colours are the
 * element's computed `color` and four border colours, so tokens go through
 * CSS on .ma-confetti; `colors` takes literal ones only, because the canvas
 * does not resolve var(). trigger="event" fires on `ma:confetti` dispatched on
 * document (detail.target empty or this element), and on a
 * `data-ma-confetti-fire` attribute already present when it binds, so the
 * script asking for a burst may run before or after this one. `confetti()`
 * from '@moonarc/core/confetti' is the same burst from your own handler.
 * Nothing fires under reduced motion.
 */
import type { HTMLAttributes } from 'astro/types';

interface Props extends HTMLAttributes<'span'> {
  /**
   * load: once, when the element is mounted (a persisted element does not burst again) · click: every click on `for`
   * (or on the parent when `for` is not given) · event: every `ma:confetti` on document whose `detail.target` is empty
   * or this element, plus once at bind when the element carries `data-ma-confetti-fire` (removed as it fires).
   */
  trigger?: 'load' | 'click' | 'event';
  /** Selector of the element whose clicks fire (click) and whose centre the burst starts from (every trigger). */
  for?: string;
  /** Pieces per burst. */
  count?: number;
  /** Literal colours (hex, rgb(), oklch()); var() is not resolved by the canvas — for tokens use CSS on .ma-confetti (`color` and the four `border-*-color`). Default: those five. */
  colors?: string[];
  /** Launch point in viewport px; default the centre of `for` (or of the clicked parent), the viewport centre on load or event without `for`. */
  origin?: { x: number; y: number };
  /** Launch direction in degrees, 0 = up, 90 = right. */
  angle?: number;
  /** Launch cone in degrees; default the preset token. */
  spread?: number;
}

const { trigger = 'click', for: target, count = 120, colors, origin, angle = 0, spread, class: className, ...rest } = Astro.props;
---

<span
  class:list={['ma-confetti', className]}
  data-ma-confetti
  data-trigger={trigger}
  data-for={target}
  data-count={count}
  data-colors={colors?.join('|')}
  data-origin={origin ? `${origin.x},${origin.y}` : undefined}
  data-angle={angle}
  data-spread={spread}
  hidden
  {...rest}
></span>

<style is:global>
  @layer components {
    /* physics per preset: px per frame at 60 fps; the script reads these on :root */
    :where(:root) {
      --ma-confetti-gravity: 0.22;
      --ma-confetti-drag: 0.985;
      --ma-confetti-velocity: 14;
      --ma-confetti-spread: 70;
      --ma-confetti-flutter: 0.12;
    }
    :where([data-ma-preset='snap']) {
      --ma-confetti-velocity: 18;
      --ma-confetti-drag: 0.975;
      --ma-confetti-spread: 50;
    }
    :where([data-ma-preset='lively']) {
      --ma-confetti-velocity: 17;
      --ma-confetti-spread: 90;
      --ma-confetti-flutter: 0.18;
    }
    :where([data-ma-preset='gentle']),
    :where([data-ma-preset='ambient']) {
      --ma-confetti-velocity: 11;
      --ma-confetti-gravity: 0.16;
      --ma-confetti-drag: 0.99;
    }
    /* the default palette: five colours the script reads from this element's computed style; override them with CSS (var() included), not through `colors` */
    :where(.ma-confetti) {
      color: #f5a524;
      border: 0 solid;
      border-top-color: #e4572e;
      border-right-color: #3a9a5b;
      border-bottom-color: #4c7dff;
      border-left-color: #c94ad3;
    }
    /* nothing fires under reduced motion (the script checks); stated here so the audit sees the branch */
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-confetti) {
        display: none;
      }
    }
  }
</style>

<script>
  import { onMount } from '../lib/runtime';
  import { colors } from '../lib/canvas';
  import { confetti } from '../lib/confetti';

  onMount<HTMLElement>('[data-ma-confetti]', (el, { signal }) => {
    const d = el.dataset;
    // every trigger is armed whatever the motion setting: confetti() reads reduced motion at the moment of the burst and
    // drops it, so a click after the reader turns motion back on bursts (bound only when motion was allowed, it never did)
    const fire = (from?: Element | null) => {
      const o = d.origin?.split(',').map(Number);
      let origin = o ? { x: o[0]!, y: o[1]! } : undefined;
      if (!origin && from) {
        const r = from.getBoundingClientRect();
        origin = { x: r.left + r.width / 2, y: r.top + r.height / 2 };
      }
      confetti({ count: Number(d.count) || 120, colors: d.colors ? d.colors.split('|') : colors(el), origin, angle: Number(d.angle) || 0, spread: d.spread ? Number(d.spread) : undefined });
    };
    const fromFor = () => fire(d.for ? document.querySelector(d.for) : null);
    // load is once per element: one kept across a swap by transition:persist is bound again after it, and must not burst again
    if (d.trigger === 'load') {
      if (!('maConfettiDone' in d)) fromFor();
      d.maConfettiDone = '';
      return;
    }
    if (d.trigger === 'event') {
      // A request made before this script ran is the attribute, one made after is the event; a caller that does both in
      // one task (set the attribute, dispatch) gets one burst whichever script runs first. Firing clears the attribute.
      const go = () => {
        delete d.maConfettiFire;
        fromFor();
      };
      if ('maConfettiFire' in d) go();
      document.addEventListener('ma:confetti', ((e: CustomEvent<{ target?: Element | null } | null>) => {
        if (!e.detail?.target || e.detail.target === el) go();
      }) as EventListener, { signal });
      return;
    }
    const target = d.for ? document.querySelector(d.for) : el.parentElement;
    target?.addEventListener('click', () => fire(target), { signal });
  });
</script>

```
