Particles
BackgroundsA field of drifting dots that part around the cursor, on a canvas that draws only while on screen in a visible tab.
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.
one canvas · gone when the last piece lands
Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.
Adjust · props are custom properties, set inline, so you edit the running instance
JavaScript of its own
2.4 kB raw
1.3 kB gzip.
Uses the shared runtime (1.9 kB raw, once per site) and the shared canvas helper (2.1 kB raw, once per site). With those included: 5.8 kB raw.
With event: the same script as the default, 2.4 kB raw. The event path is part of it and adds nothing; measured on its own fixture.
CSS 4.7 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.
Browser support
every browser.
One command adds the integration, the base tokens and every component. Then import what you use.
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarc---
import Confetti from '@moonarc/core/Confetti';
---
<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>Owns the file, no dependency. The registry item also installs confetti(), the canvas helper, the shared runtime and the base tokens.
npx shadcn@latest add https://moonarc.dev/r/confetti.jsonThe CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.
The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy confetti.ts, canvas.ts and runtime.ts.
---
/**
* 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>| 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. |
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.
ClientRouterBound 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.
Replaces: Confetti (Magic UI) · canvas-confetti · react-confetti. See the migration table.
A field of drifting dots that part around the cursor, on a canvas that draws only while on screen in a visible tab.
idle → pending → done · one attribute
One submit button with four faces (idle, pending with a spinner, done with a check that draws itself, error) and a width that follows the label.
Notifications that slide in, stack behind each other, push up as new ones arrive, and expand into a list on hover.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown