Skip to content
Brok UI

Loading…

No results

Exploding Input

Open source

A playful text input that bursts token-coloured glyphs or confetti chips from the caret on every keystroke, with a small kick on the field; the particles fly up, drift, rotate and fade as self-removing DOM nodes. Composes the input primitive and is a no-op under reduced motion.

Version
v1.1.2
Stability
stable
License
MIT
Related
Input
Confetti
Apple Hello Effect

Preview

previews.components.exploding-input.default.blade.php Blade
{{-- Type: glyphs burst from the caret and the field gives a small kick. --}}
<div class="flex h-56 w-full max-w-md items-center justify-center rounded-lg border border-border bg-card">
    <x-ui.exploding-input placeholder="Type something…" class="w-72" />
</div>

Installation

terminal
php artisan ui:add exploding-input

Note

This component ships an Alpine behavior module at resources/js/ui/exploding-input.js. Import it once from your bundle so it registers on alpine:init:

resources/js/ui/index.js JS
import './exploding-input.js';

Registry contract

php artisan ui:add exploding-input 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/exploding-input.blade.php
  • js resources/js/ui/exploding-input.js
Registry dependencies
input
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.

exploding-input.md
# Brok UI: Exploding Input (`exploding-input`)

A playful text input that bursts token-coloured glyphs or confetti chips from the caret on every keystroke, with a small kick on the field; the particles fly up, drift, rotate and fade as self-removing DOM nodes. Composes the input primitive and is a no-op under reduced motion.

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add exploding-input
```

## Usage

```blade
{{-- Type: glyphs burst from the caret and the field gives a small kick. --}}
<div class="flex h-56 w-full max-w-md items-center justify-center rounded-lg border border-border bg-card">
    <x-ui.exploding-input placeholder="Type something…" class="w-72" />
</div>
```

## Props

- `count` (int, default `6`) — Particles spawned per keystroke, clamped between 1 and 16.
- `durationMs` (int, default `800`) — Milliseconds each particle takes to fly out and fade, clamped between 120 and 4000.
- `spread` (int, default `110`) — Horizontal angle, in degrees, the particles fan out across.
- `rise` (int, default `56`) — Vertical distance, in pixels, particles travel upward before fading.
- `glyphs` (array, default `['✦', '✧', '★', '✺', '❉', '✸']`) — Glyph characters cycled through when particles is glyphs or mixed.
- `particles` (string, default `glyphs`) — What flies out of the caret: glyphs, confetti, or mixed.
- `kick` (bool, default `true`) — Plays a short scale kick on the field itself with every burst.
- `placeholder` (mixed|null, default `null`) — Placeholder text forwarded to the composed input.

## Use when

- Use when users must enter freeform information that cannot be reliably selected from a list.
- Adding a playful, on-brand micro-interaction to a low-stakes text field, such as a marketing or onboarding form.
- Wanting keystroke feedback that does not change the field's markup or submitted value.

## Avoid when

- Do not choose a freeform field when a constrained choice would reduce errors or cognitive load.
- A dense form or business application where the particle burst would read as noisy; use input instead.
- A field used mainly by keyboard-only or reduced-motion users who would gain nothing from it; the particles are a no-op under reduced motion, so keep it out of any field where distraction is a bigger risk than delight.

## Anti-patterns

- Using freeform entry for a bounded answer set

## Rules

- Use the `<brok:exploding-input>` tag (or `<x-ui.exploding-input>`) 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/exploding-input
- Registry JSON (files, props, contract): https://brokui.dev/r/open/exploding-input.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

Examples

confetti.blade.php Blade
{{-- Confetti chips instead of glyphs, denser and wider; `mixed` uses both. --}}
<div class="flex h-56 w-full max-w-md items-center justify-center rounded-lg border border-border bg-card">
    <x-ui.exploding-input particles="confetti" :count="10" :spread="140" :rise="64" placeholder="Celebrate every key…" class="w-72" />
</div>

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
count int 6 Particles spawned per keystroke, clamped between 1 and 16.
durationMs int 800 Milliseconds each particle takes to fly out and fade, clamped between 120 and 4000.
spread int 110 Horizontal angle, in degrees, the particles fan out across.
rise int 56 Vertical distance, in pixels, particles travel upward before fading.
glyphs array ['✦', '✧', '★', '✺', '❉', '✸'] Glyph characters cycled through when particles is glyphs or mixed.
particles string glyphs What flies out of the caret: glyphs, confetti, or mixed.
kick bool true Plays a short scale kick on the field itself with every burst.
placeholder mixed | null null Placeholder text forwarded to the composed input.

Slots

  • default — Unused; the field is composed from the input primitive.

Data slots

Stable hooks for CSS overrides and browser tests.

exploding-input exploding-input-particle

Behavior

  • Spawns a short-lived burst of DOM particles from the caret on every keystroke; each particle flies up, rotates, fades and removes itself on animationend.
  • Particle colours come from cycled semantic token classes, never hard-coded colours.
  • Under prefers-reduced-motion the burst and kick are a no-op and the field behaves as a plain input.
  • 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

Freeform text input

Collect unpredictable freeform information.

Use when

  • Use when users must enter freeform information that cannot be reliably selected from a list.
  • Adding a playful, on-brand micro-interaction to a low-stakes text field, such as a marketing or onboarding form.
  • Wanting keystroke feedback that does not change the field's markup or submitted value.

Avoid when

  • Do not choose a freeform field when a constrained choice would reduce errors or cognitive load.
  • A dense form or business application where the particle burst would read as noisy; use input instead.
  • A field used mainly by keyboard-only or reduced-motion users who would gain nothing from it; the particles are a no-op under reduced motion, so keep it out of any field where distraction is a bigger risk than delight.

Use instead

  • Radio or checkbox for bounded choices
  • Select or combobox for known options

Anti-patterns

  • Using freeform entry for a bounded answer set
Anatomy
root exploding-input-particle
Theming hooks
exploding-input exploding-input-particle

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
managed
Focus
none
  • The particle layer is aria-hidden and pointer-events-none, so it never intercepts input or clutters the accessibility tree.
  • The field itself is a plain, fully functional input; the burst carries no information required to complete the form.
  • 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>
    {{-- Type: glyphs burst from the caret and the field gives a small kick. --}}
    <div class="flex h-56 w-full max-w-md items-center justify-center rounded-lg border border-border bg-card">
        <x-ui.exploding-input placeholder="Type something…" class="w-72" />
    </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/exploding-input.blade.php Blade
@props([
    'count' => 6,
    'durationMs' => 800,
    'spread' => 110,
    'rise' => 56,
    'glyphs' => ['✦', '✧', '★', '✺', '❉', '✸'],
    // glyphs | confetti | mixed — what flies out of the caret.
    'particles' => 'glyphs',
    // A short scale kick on the field with every burst.
    'kick' => true,
    'placeholder' => null,
])

@php
    // A text input that emits a tiny burst of glyph particles on each keystroke —
    // a playful micro-interaction. It COMPOSES the <x-ui.input> primitive (so it
    // inherits the `border-input` token styling) and spawns lightweight DOM
    // <span> particles into an absolutely-positioned layer. Each particle flies
    // up/out, rotates, and fades, then removes itself on `animationend`. Particle
    // colours are SEMANTIC token text classes cycled in JS — nothing hard-coded.
    // Reduced-motion users get a plain, fully functional input (the burst is a
    // no-op). No window/document listeners: the keydown hook lives on the input.
    $palette = collect(is_array($glyphs) ? $glyphs : [$glyphs])
        ->map(fn ($g) => (string) $g)
        ->filter(fn ($g) => $g !== '')
        ->values()
        ->all();

    $particleStyles = ['glyphs', 'confetti', 'mixed'];
    $config = [
        'count' => max(1, min(16, (int) $count)),
        'durationMs' => max(120, min(4000, (int) $durationMs)),
        'spread' => max(0, min(180, (float) $spread)),
        'rise' => max(8, min(200, (float) $rise)),
        'glyphs' => $palette !== [] ? $palette : ['✦', '✧', '★', '✺', '❉', '✸'],
        'particles' => in_array($particles, $particleStyles, true) ? $particles : 'glyphs',
        'kick' => (bool) $kick,
    ];
@endphp

<div
    data-slot="exploding-input"
    x-data="uiExplodingInput(@js($config))"
    x-ref="root"
    {{ $attributes->merge(['class' => 'relative inline-block']) }}
>
    <x-ui.input
        x-ref="input"
        x-on:keydown="burst($event)"
        :placeholder="$placeholder"
        class="motion-safe:data-[kick=true]:animate-[ui-exploding-input-kick_180ms_cubic-bezier(0.22,1,0.36,1)]"
    />

    {{-- The particle layer. Decorative, never interactive; sits over the input
         and lets glyphs overflow its bounds as they fly out. --}}
    <div
        x-ref="layer"
        aria-hidden="true"
        class="pointer-events-none absolute inset-0 z-10 overflow-visible"
    ></div>

    {{-- Keyframes carry NO colour (tokens stay the single source of truth) and
         read per-particle CSS custom properties set inline at spawn. Gated on
         no-preference so reduced-motion users never see motion; the JS is also a
         no-op under reduce, so this is belt-and-braces. --}}
    <style>
        @keyframes ui-exploding-input-pop {
            0% {
                opacity: 0;
                transform: translate3d(0, 0, 0) rotate(0deg) scale(0.6);
            }
            12% {
                opacity: 1;
                transform: translate3d(calc(var(--ex-dx) * 0.2), calc(var(--ex-dy) * 0.18), 0)
                    rotate(calc(var(--ex-rot) * 0.2)) scale(1);
            }
            100% {
                opacity: 0;
                transform: translate3d(var(--ex-dx), var(--ex-dy), 0)
                    rotate(var(--ex-rot)) scale(0.85);
            }
        }
        @keyframes ui-exploding-input-kick {
            0% { transform: scale(1); }
            35% { transform: scale(1.015); }
            100% { transform: scale(1); }
        }
        @media (prefers-reduced-motion: no-preference) {
            [data-slot="exploding-input"] [data-slot="exploding-input-particle"] {
                animation-name: ui-exploding-input-pop;
                animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
                animation-fill-mode: forwards;
                animation-iteration-count: 1;
                will-change: transform, opacity;
            }
        }
    </style>
</div>
resources/js/ui/exploding-input.js JS
/**
 * Exploding input behavior (a playful per-keystroke glyph burst).
 *
 * The host wraps an <x-ui.input> and a pointer-events-none particle layer. On
 * each printable keystroke `burst(e)`:
 *   1. ignores modifier / navigation keys (only single printable characters
 *      spawn particles), so Tab/Arrows/Backspace stay quiet;
 *   2. spawns `count` lightweight <span> particles at the caret (its x is
 *      measured with a mirror of the typed text in the input's own font), each
 *      a random glyph or a confetti chip in a random SEMANTIC token colour
 *      class (`text-primary`, `bg-info`, …) — never a hard-coded colour;
 *   3. fans each particle over `spread` degrees biased upward, sets per-particle
 *      CSS custom properties (`--ex-dx`, `--ex-dy`, `--ex-rot`, `--ex-dur`) that
 *      the scoped @keyframes read, and removes the node on `animationend`
 *      ({ once: true }) so nothing accumulates.
 *
 * No window/document listeners: the keydown hook is bound on the input element
 * in Blade. Reduced motion: `burst()` is a no-op so typing stays calm. The
 * factory 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('uiExplodingInput', (config = {}) => ({
        count: Math.max(1, Math.min(16, config.count ?? 6)),
        durationMs: Math.max(120, Math.min(4000, config.durationMs ?? 800)),
        spread: Math.max(0, Math.min(180, config.spread ?? 110)),
        rise: Math.max(8, Math.min(200, config.rise ?? 56)),
        particles: ['glyphs', 'confetti', 'mixed'].includes(config.particles) ? config.particles : 'glyphs',
        kick: config.kick ?? true,
        glyphs:
            Array.isArray(config.glyphs) && config.glyphs.length
                ? config.glyphs
                : ['✦', '✧', '★', '✺', '❉', '✸'],

        // Semantic token text classes only — theming flows straight through.
        _tokenClasses: ['text-primary', 'text-info', 'text-success', 'text-warning', 'text-destructive'],
        _chipClasses: ['bg-primary', 'bg-info', 'bg-success', 'bg-warning', 'bg-destructive'],
        _sizeClasses: ['text-xs', 'text-sm', 'text-base'],
        _chipSizes: ['size-1.5', 'h-1.5 w-2', 'size-2'],

        _reduced: false,
        _mirror: null,
        _kickTimer: null,

        init() {
            this._reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
        },

        // Only spawn for an actual character keypress. Modifier-combos and
        // navigation/editing keys (whose `key` is a multi-char name) are skipped.
        _isPrintable(e) {
            if (e.ctrlKey || e.metaKey || e.altKey) return false;
            return typeof e.key === 'string' && e.key.length === 1;
        },

        _pick(list) {
            return list[Math.floor(Math.random() * list.length)];
        },

        /** X of the caret inside the input, measured on a mirror of the text before it. */
        _caretX(input) {
            const cs = getComputedStyle(input);
            if (!this._mirror) {
                this._mirror = document.createElement('span');
                this._mirror.setAttribute('aria-hidden', 'true');
                this._mirror.style.cssText = 'position:absolute;visibility:hidden;white-space:pre;top:0;left:0;pointer-events:none;';
                this.$refs.root.appendChild(this._mirror);
            }
            for (const prop of ['fontFamily', 'fontSize', 'fontWeight', 'letterSpacing', 'textTransform']) {
                this._mirror.style[prop] = cs[prop];
            }
            const caret = input.selectionStart ?? input.value.length;
            this._mirror.textContent = input.value.slice(0, caret) || '';
            const textWidth = this._mirror.getBoundingClientRect().width;
            const padStart = parseFloat(cs.paddingLeft) || 0;
            const padEnd = parseFloat(cs.paddingRight) || 0;
            const inner = input.clientWidth - padStart - padEnd;
            return Math.max(padStart, Math.min(padStart + inner, padStart + textWidth - input.scrollLeft));
        },

        burst(e) {
            if (this._reduced) return;
            if (!this._isPrintable(e)) return;

            const layer = this.$refs.layer;
            const input = this.$refs.input;
            if (!layer || !input) return;

            // Origin = the caret, in layer-local coordinates (the burst follows
            // the typing, not the field's edge). RTL text grows from the end edge.
            const layerRect = layer.getBoundingClientRect();
            const inputRect = input.getBoundingClientRect();
            const isRtl = getComputedStyle(input).direction === 'rtl';
            const caretX = this._caretX(input);
            const originX = (isRtl ? inputRect.right - caretX : inputRect.left + caretX) - layerRect.left;
            const originY = inputRect.top + inputRect.height / 2 - layerRect.top;

            if (this.kick) {
                clearTimeout(this._kickTimer);
                input.removeAttribute('data-kick');
                void input.offsetWidth; // restart the kick keyframe on rapid typing
                input.setAttribute('data-kick', 'true');
                this._kickTimer = setTimeout(() => input.removeAttribute('data-kick'), 200);
            }

            const half = (this.spread * Math.PI) / 360; // half-spread in radians

            for (let i = 0; i < this.count; i++) {
                const span = document.createElement('span');
                span.setAttribute('data-slot', 'exploding-input-particle');
                span.setAttribute('aria-hidden', 'true');
                const chip = this.particles === 'confetti' || (this.particles === 'mixed' && Math.random() < 0.5);
                if (chip) {
                    span.classList.add('absolute', 'block', 'rounded-full', this._pick(this._chipClasses), ...this._pick(this._chipSizes).split(' '));
                } else {
                    span.classList.add('absolute', 'select-none', 'leading-none', this._pick(this._tokenClasses), this._pick(this._sizeClasses));
                    span.textContent = this._pick(this.glyphs);
                }

                // Fan around straight up (-90°) within ±half; outward drift is the
                // horizontal component, rise is the (fixed, upward) vertical travel.
                const angle = -Math.PI / 2 + (Math.random() * 2 - 1) * half;
                const dx = Math.cos(angle) * (this.rise * (0.5 + Math.random() * 0.5));
                const dy = -this.rise * (0.7 + Math.random() * 0.5);
                const rot = (Math.random() * 2 - 1) * (chip ? 360 : 120);
                const dur = this.durationMs * (0.8 + Math.random() * 0.4);

                span.style.left = `${originX}px`;
                span.style.top = `${originY}px`;
                span.style.setProperty('--ex-dx', `${dx.toFixed(2)}px`);
                span.style.setProperty('--ex-dy', `${dy.toFixed(2)}px`);
                span.style.setProperty('--ex-rot', `${rot.toFixed(2)}deg`);
                span.style.setProperty('--ex-dur', `${Math.round(dur)}ms`);
                span.style.animationDuration = `${Math.round(dur)}ms`;

                span.addEventListener('animationend', () => span.remove(), { once: true });
                layer.appendChild(span);
            }
        },

        destroy() {
            // Clear any particles still mid-flight when the component unmounts.
            clearTimeout(this._kickTimer);
            const layer = this.$refs.layer;
            if (layer) layer.replaceChildren();
            this._mirror?.remove();
        },
    }));
});

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.1.2
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