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.
Preview
{{-- 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
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:
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.
-
resources/views/components/ui/exploding-input.blade.php -
resources/js/ui/exploding-input.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: 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 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
Props
| 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.
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
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
- Theming hooks
Accessibility
- 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-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>
{{-- 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.
@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>
/**
* 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