Confetti
A celebratory confetti burst fired on click from a trigger button, drawn on a viewport canvas with token-coloured pieces, gravity and self-terminating animation. Reduced-motion safe.
Preview
<div class="flex h-56 w-full max-w-md items-center justify-center rounded-lg border border-border bg-card">
<x-ui.confetti label="Celebrate" />
</div>
Installation
php artisan ui:add confetti
Note
This component ships an Alpine behavior module at
resources/js/ui/confetti.js. Import it once from your bundle so it registers on alpine:init:
import './confetti.js';
Registry contract
php artisan ui:add confetti
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/confetti.blade.php -
resources/js/ui/confetti.js
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Confetti (`confetti`)
A celebratory confetti burst fired on click from a trigger button, drawn on a viewport canvas with token-coloured pieces, gravity and self-terminating animation. Reduced-motion safe.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add confetti
```
## Usage
```blade
<div class="flex h-56 w-full max-w-md items-center justify-center rounded-lg border border-border bg-card">
<x-ui.confetti label="Celebrate" />
</div>
```
## Props
- `label` (mixed|null, default `null`) — Label for the default trigger button when no slot is supplied; falls back to the localized "Celebrate".
- `count` (int, default `90`) — Number of confetti pieces per burst, clamped between 1 and 400.
- `spread` (int, default `70`) — Burst cone width in degrees, clamped between 1 and 360.
- `colors` (string, default `primary,secondary,accent,success,warning`) — Comma-separated semantic token names resolved to concrete colours at fire time.
## Use when
- Use sparingly to celebrate significant completion or add a memorable moment.
- Celebrating a completed action, such as a checkout, sign-up, or achievement, with a one-shot particle burst.
- Wanting a fully token-coloured, self-terminating burst that fires on click without a persistent idle animation.
## Avoid when
- Do not inject celebration into routine high-frequency workflows or serious contexts.
- Needing a looping or ambient background effect; the burst is a one-shot reaction to a click, not continuous motion. Use gradient-bars or dither-gradient for continuous ambient motion instead.
- Needing visual feedback under reduced motion; fire() is a no-op there, so only the trigger's own state communicates the click.
## Anti-patterns
- Celebration in routine, serious, or high-frequency workflows
## Rules
- Use the `<brok:confetti>` tag (or `<x-ui.confetti>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/confetti
- Registry JSON (files, props, contract): https://brokui.dev/r/open/confetti.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| label | mixed | null | null | Label for the default trigger button when no slot is supplied; falls back to the localized "Celebrate". |
| count | int | 90 | Number of confetti pieces per burst, clamped between 1 and 400. |
| spread | int | 70 | Burst cone width in degrees, clamped between 1 and 360. |
| colors | string | primary,secondary,accent,success,warning | Comma-separated semantic token names resolved to concrete colours at fire time. |
Slots
default— Custom trigger content; falls back to a default button labelled by the label prop.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Clicking anywhere inside the wrapper fires the burst from the trigger's centre onto a fixed, pointer-events-none canvas pinned to the viewport.
- The animation loop applies gravity and drag to each piece and self-terminates once every piece has settled; under reduced motion, fire() is a no-op.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized, reducedMotion.
Guidance
Celebrate a meaningful completion moment.
Use when
- Use sparingly to celebrate significant completion or add a memorable moment.
- Celebrating a completed action, such as a checkout, sign-up, or achievement, with a one-shot particle burst.
- Wanting a fully token-coloured, self-terminating burst that fires on click without a persistent idle animation.
Avoid when
- Do not inject celebration into routine high-frequency workflows or serious contexts.
- Needing a looping or ambient background effect; the burst is a one-shot reaction to a click, not continuous motion. Use gradient-bars or dither-gradient for continuous ambient motion instead.
- Needing visual feedback under reduced motion; fire() is a no-op there, so only the trigger's own state communicates the click.
Use instead
- Concise success message
Anti-patterns
- Celebration in routine, serious, or high-frequency workflows
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- managed
- Focus
none
- The canvas is aria-hidden and pointer-events-none, so it never intercepts focus or clicks.
- The trigger stays a normal, focusable button (or the supplied slot content) with its own accessible name.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add wire:ignore to the component root because its behavior owns rendered DOM.
<div wire:ignore>
<div class="flex h-56 w-full max-w-md items-center justify-center rounded-lg border border-border bg-card">
<x-ui.confetti label="Celebrate" />
</div>
</div>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
'label' => null,
'count' => 90,
'spread' => 70,
'colors' => 'primary,secondary,accent,success,warning',
])
@php
// A celebratory confetti burst. Clicking the trigger fires a cone of token-
// coloured pieces from the trigger's position onto a fixed, pointer-events-
// none canvas; the rAF loop self-terminates once every piece has settled, so
// there is no idle animation. The piece colours are resolved at runtime from
// the named semantic tokens (`--primary`, …) — nothing is hard-coded, and
// reduced-motion users get a no-op (the trigger still works).
$tokens = collect(explode(',', (string) $colors))
->map(fn ($t) => trim($t))
->filter()
->values()
->all();
$config = [
'count' => max(1, min(400, (int) $count)),
'spread' => max(1, min(360, (float) $spread)),
// Semantic token names → resolved to concrete colours in JS at fire time.
'tokens' => $tokens !== [] ? $tokens : ['primary', 'secondary', 'accent'],
];
@endphp
<div
data-slot="confetti"
x-data="uiConfetti(@js($config))"
x-ref="root"
{{ $attributes->merge(['class' => 'relative inline-block']) }}
>
{{-- The burst surface: pinned to the viewport, above content, never clickable. --}}
<canvas
x-ref="canvas"
aria-hidden="true"
class="pointer-events-none fixed inset-0 z-toast h-full w-full"
></canvas>
{{-- The trigger. A consumer slot replaces the default button; clicking
anywhere inside fires the burst (the event bubbles up to this wrapper).
The burst origin is taken from the root box, not this span — `contents`
has no layout box of its own. --}}
<span @click="fire()" class="contents">
@if (trim($slot) !== '')
{{ $slot }}
@else
<x-ui.button type="button">{{ $label ?? __('Celebrate') }}</x-ui.button>
@endif
</span>
</div>
/**
* Confetti behavior (a celebratory burst triggered on click).
*
* The host wraps a trigger and a fixed, pointer-events-none <canvas> pinned to
* the viewport. On `fire()` we:
* 1. resolve the configured semantic token names (`primary`, `accent`, …) to
* concrete colours via a throwaway probe element, so pieces match the theme;
* 2. spawn `count` pieces from the trigger's centre with velocities fanned out
* over `spread` degrees (biased upward), each a small rotating rectangle;
* 3. run a rAF loop that applies gravity + drag, spins and fades each piece,
* and removes settled pieces. The loop STOPS when the last piece is gone —
* there is no idle animation.
*
* Colour is SEMANTIC: every piece colour comes from a design token, never a
* hard-coded palette value. Reduced motion: `fire()` is a no-op so the trigger
* stays usable without a sudden burst of movement.
*
* Self-registers on `alpine:init` so import order does not matter.
*
* Note: this file is plain JS served by the registry — no Blade syntax allowed.
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiConfetti', (config = {}) => ({
count: Math.max(1, config.count ?? 90),
spread: Math.max(1, config.spread ?? 70),
tokens: Array.isArray(config.tokens) && config.tokens.length ? config.tokens : ['primary', 'secondary', 'accent'],
_ctx: null,
_dpr: 1,
_cssW: 0,
_cssH: 0,
_pieces: [],
_colors: [],
_raf: null,
_reduced: false,
init() {
this._reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
this._ctx = this.$refs.canvas.getContext('2d');
},
// Resolve each semantic token through the foundation helper. It prefers
// full-color aliases (including OKLCH) and falls back to legacy channels.
resolveColors() {
const out = this.tokens
.map((name) => window.UI?.resolveColor?.(name, this.$refs.root, ''))
.filter(Boolean);
this._colors = out.length ? out : [getComputedStyle(document.body).color];
},
// Match the backing store to the viewport for crisp pieces at any DPR.
sizeCanvas() {
const canvas = this.$refs.canvas;
this._dpr = Math.max(1, window.devicePixelRatio || 1);
this._cssW = Math.max(1, window.innerWidth);
this._cssH = Math.max(1, window.innerHeight);
canvas.width = Math.round(this._cssW * this._dpr);
canvas.height = Math.round(this._cssH * this._dpr);
this._ctx.setTransform(this._dpr, 0, 0, this._dpr, 0, 0);
},
fire() {
if (this._reduced) return;
this.sizeCanvas();
this.resolveColors();
// Origin = centre of the trigger, in viewport (CSS) pixels. We measure
// the root (an inline-block wrapper whose box equals the trigger's) —
// the click target itself is `display:contents`, which has no box.
const rect = this.$refs.root.getBoundingClientRect();
const ox = rect.left + rect.width / 2;
const oy = rect.top + rect.height / 2;
const half = (this.spread * Math.PI) / 360; // half-spread in radians
for (let i = 0; i < this.count; i++) {
// Fan around straight up (-90°), within ±half.
const angle = -Math.PI / 2 + (Math.random() * 2 - 1) * half;
const velocity = 6 + Math.random() * 8;
this._pieces.push({
x: ox,
y: oy,
vx: Math.cos(angle) * velocity,
vy: Math.sin(angle) * velocity,
w: 6 + Math.random() * 6,
h: 3 + Math.random() * 4,
rotation: Math.random() * Math.PI * 2,
spin: (Math.random() - 0.5) * 0.4,
alpha: 1,
color: this._colors[Math.floor(Math.random() * this._colors.length)],
});
}
if (!this._raf) this._raf = requestAnimationFrame(() => this.loop());
},
loop() {
const ctx = this._ctx;
ctx.clearRect(0, 0, this._cssW, this._cssH);
const gravity = 0.22;
const drag = 0.985;
for (let i = this._pieces.length - 1; i >= 0; i--) {
const p = this._pieces[i];
p.vx *= drag;
p.vy = p.vy * drag + gravity;
p.x += p.vx;
p.y += p.vy;
p.rotation += p.spin;
// Begin fading once the piece is past its apex / falling.
if (p.vy > 0) p.alpha -= 0.008;
if (p.alpha <= 0 || p.y - p.h > this._cssH) {
this._pieces.splice(i, 1);
continue;
}
ctx.save();
ctx.globalAlpha = Math.max(0, p.alpha);
ctx.translate(p.x, p.y);
ctx.rotate(p.rotation);
ctx.fillStyle = p.color;
ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
ctx.restore();
}
if (this._pieces.length > 0) {
this._raf = requestAnimationFrame(() => this.loop());
} else {
// No idle loop: stop entirely and clear the surface.
ctx.clearRect(0, 0, this._cssW, this._cssH);
this._raf = null;
}
},
destroy() {
if (this._raf) {
cancelAnimationFrame(this._raf);
this._raf = null;
}
this._pieces = [];
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.0.4 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0