Skip to content
Brok UI

Loading…

No results

Confetti

Open source

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.

Version
v1.0.4
Stability
stable
License
MIT
Related
Apple Hello Effect
Text Animate
Gradient Bars

Preview

previews.components.confetti.default.blade.php 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>

Installation

terminal
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:

resources/js/ui/index.js JS
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.

  • blade resources/views/components/ui/confetti.blade.php
  • js resources/js/ui/confetti.js
Registry dependencies
button
Packages
composer: jml/brok:^0.2
npm: alpinejs

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.

confetti.md
# 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

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
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.

confetti

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

Celebration effect

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
root
Theming hooks
confetti

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, 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 under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:ignore

Add wire:ignore to the component root because its behavior owns rendered DOM.

livewire-component.blade.php Blade
<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.

resources/views/components/ui/confetti.blade.php Blade
@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>
resources/js/ui/confetti.js JS
/**
 * 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