Exploding Input
An email-capture input that shoots cute icon projectiles out of the caret as you type, with a full-width volley on submit.
Join the waitlist
Shoot cute elements out of your input as you type.
The waitlist input that makes people want to type their email twice. Every keystroke launches a small colored glyph from the exact caret position on a ballistic arc: it decelerates to its apex, then gravity takes over and it tumbles past the field. Submitting fires a volley across the input's full width while the button morphs its label into a check. Rebuilt from Lochie's exploding input for Framer. Built with Motion.
Type into the field: each character fires one projectile from the caret. Press Enter or hit Join for the full send-off.
Installation
Complete the shared Setup first, then copy the component into
components/ui/exploding-input.tsx.
bun add motion'use client';import * as React from 'react';import { motion, useReducedMotion } from 'motion/react';import { Bell, Check, Heart, Send, Smile, Sparkles, Star, ThumbsUp, Zap, type LucideIcon,} from 'lucide-react';import { cn } from '@/lib/cn';const EASE_OUT = [0.23, 1, 0.32, 1] as const;const EASE_IN_FALL = [0.55, 0, 1, 0.45] as const;const FOCUS = 'focus-visible:outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:[outline-color:var(--explode-accent)]';function seeded(n: number) { const x = Math.sin(n * 12.9898) * 43758.5453; return x - Math.floor(x);}const DEFAULT_ICONS: LucideIcon[] = [ Smile, Heart, Star, Bell, Send, Zap, ThumbsUp, Sparkles,];const DEFAULT_COLORS = ['#f0883e', '#3ecf8e', '#22d3ee', '#a78bfa', '#fb7185'];const MAX_PARTICLES = 24;const INPUT_PAD_X = 14;type Particle = { id: number; x: number; dx: number; rise: number; fall: number; rot: number; dur: number; size: number; glyph: number; color: number;};export type ExplodingInputProps = { defaultValue?: string; placeholder?: string; buttonLabel?: string; accent?: string; icons?: LucideIcon[]; colors?: string[]; type?: React.HTMLInputTypeAttribute; label?: string; onSubmit?: (value: string) => void; className?: string;};export function ExplodingInput({ defaultValue = '', placeholder = 'you@company.com', buttonLabel = 'Join', accent = '#f0883e', icons = DEFAULT_ICONS, colors = DEFAULT_COLORS, type = 'email', label = 'Email address', onSubmit, className,}: ExplodingInputProps) { const reduce = useReducedMotion(); const inputRef = React.useRef<HTMLInputElement>(null); const mirrorRef = React.useRef<HTMLSpanElement>(null); const seedRef = React.useRef(1); const prevLenRef = React.useRef(defaultValue.length); const sentTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null); const [particles, setParticles] = React.useState<Particle[]>([]); const [sent, setSent] = React.useState(false); React.useEffect(() => { return () => { if (sentTimer.current) clearTimeout(sentTimer.current); }; }, []); const makeParticle = React.useCallback( (x: number, boost = 1): Particle => { const n = seedRef.current++; const r = (salt: number) => seeded(n * 7.13 + salt); return { id: n, x, dx: (r(1) - 0.5) * 130 * boost, rise: (30 + r(2) * 46) * boost, fall: 56 + r(3) * 44, rot: (r(4) - 0.5) * 260, dur: 0.8 + r(5) * 0.35, size: 15 + Math.round(r(6) * 7), glyph: Math.floor(r(7) * icons.length) % icons.length, color: Math.floor(r(8) * colors.length) % colors.length, }; }, [icons.length, colors.length], ); const push = React.useCallback((next: Particle[]) => { setParticles((prev) => [...prev, ...next].slice(-MAX_PARTICLES)); }, []); const remove = React.useCallback((id: number) => { setParticles((prev) => prev.filter((p) => p.id !== id)); }, []); const caretX = React.useCallback(() => { const input = inputRef.current; const mirror = mirrorRef.current; if (!input || !mirror) return INPUT_PAD_X; const end = input.selectionStart ?? input.value.length; mirror.textContent = input.value.slice(0, end); const x = INPUT_PAD_X + mirror.offsetWidth - input.scrollLeft; return Math.max(10, Math.min(x, input.offsetWidth - 10)); }, []); const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { const len = e.target.value.length; const grew = len > prevLenRef.current; prevLenRef.current = len; if (grew && !reduce) push([makeParticle(caretX())]); }; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); const input = inputRef.current; const value = input?.value.trim() ?? ''; if (!value || sent) return; if (!reduce && input) { const w = input.offsetWidth; const count = 10; const volley = Array.from({ length: count }, (_, i) => { const jitter = seeded(seedRef.current * 3.7 + i); const x = 12 + ((w - 24) * (i + jitter)) / count; return makeParticle(x, 1.6); }); push(volley); } onSubmit?.(value); setSent(true); if (input) { input.value = ''; prevLenRef.current = 0; } sentTimer.current = setTimeout(() => setSent(false), 1600); }; return ( <div style={{ '--explode-accent': accent } as React.CSSProperties} className={cn('relative', className)} > <form onSubmit={handleSubmit} className="flex items-center gap-2"> <div className="relative min-w-0 flex-1"> <input ref={inputRef} type={type} aria-label={label} placeholder={placeholder} defaultValue={defaultValue} onChange={handleChange} autoComplete="email" className={cn( 'bloom-edge h-10 w-full rounded-[11px] px-3.5', 'bg-black/[0.02] dark:bg-black/20', 'shadow-[inset_0_1px_2px_0_rgba(0,0,0,0.04)] dark:shadow-[inset_0_1px_3px_0_rgba(0,0,0,0.5)]', 'text-sm font-medium tracking-[-0.01em] text-neutral-900 dark:text-white', 'placeholder:text-neutral-400 dark:placeholder:text-[#8b8b8b]', FOCUS, )} /> <span ref={mirrorRef} aria-hidden className="pointer-events-none invisible absolute left-3.5 top-0 whitespace-pre text-sm font-medium tracking-[-0.01em]" /> </div> <motion.button type="submit" disabled={sent} whileTap={reduce ? undefined : { scale: 0.97 }} transition={{ duration: 0.16, ease: EASE_OUT }} style={{ backgroundColor: 'var(--explode-accent)' }} className={cn( 'h-10 shrink-0 cursor-pointer rounded-[10px] px-4', 'text-sm font-semibold tracking-[-0.01em] text-[#141612]', 'shadow-[inset_0_1px_0_0_rgba(255,255,255,0.25),0_1px_2px_0_rgba(0,0,0,0.15)]', 'disabled:cursor-default', FOCUS, )} > <span className="grid place-items-center [&>*]:col-start-1 [&>*]:row-start-1"> <motion.span animate={{ opacity: sent ? 0 : 1, filter: reduce ? 'blur(0px)' : sent ? 'blur(4px)' : 'blur(0px)', }} transition={{ duration: 0.2, ease: EASE_OUT }} > {buttonLabel} </motion.span> <motion.span initial={false} animate={{ opacity: sent ? 1 : 0, scale: reduce ? 1 : sent ? 1 : 0.8, }} transition={{ duration: 0.2, ease: EASE_OUT }} > <Check size={16} strokeWidth={2.5} aria-hidden /> <span className="sr-only">Submitted</span> </motion.span> </span> </motion.button> </form> <div aria-hidden className="pointer-events-none absolute inset-0 select-none"> {particles.map((p) => { const Glyph = icons[p.glyph] ?? Smile; const color = colors[p.color] ?? accent; return ( <motion.span key={p.id} className="absolute" style={{ left: p.x, top: 2, color }} initial={{ x: 0, y: 0, rotate: 0, scale: 0.5, opacity: 1 }} animate={{ x: [0, p.dx * 0.7, p.dx], y: [0, -p.rise, p.fall], rotate: [0, p.rot], scale: [0.5, 1.05, 0.95], opacity: [1, 1, 0], }} transition={{ duration: p.dur, times: [0, 0.42, 1], ease: [EASE_OUT, EASE_IN_FALL], x: { duration: p.dur, times: [0, 0.42, 1], ease: 'linear' }, rotate: { duration: p.dur, ease: 'linear' }, scale: { duration: p.dur, times: [0, 0.42, 1], ease: EASE_OUT }, opacity: { duration: p.dur, times: [0, 0.62, 1], ease: 'linear', }, }} onAnimationComplete={() => remove(p.id)} > <Glyph size={p.size} strokeWidth={2} fill="currentColor" fillOpacity={0.25} className="drop-shadow-[0_1px_1px_rgba(0,0,0,0.12)]" /> </motion.span> ); })} </div> </div> );}Usage
import { ExplodingInput } from '@/components/ui/exploding-input';
export default function Example() {
return <ExplodingInput onSubmit={(email) => console.log(email)} />;
}Examples
Pass any lucide icons; the accent recolors the button and focus outlines while the projectile palette stays independent.
Launching soon
Bring your own glyphs and accent.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
defaultValue | string | "" | Uncontrolled initial value. |
placeholder | string | "you@company.com" | Input placeholder. |
buttonLabel | string | "Join" | Label on the submit button. |
accent | string | "#f0883e" | Button fill and focus outline color. |
icons | LucideIcon[] | 8 playful glyphs | The icons that shoot out. |
colors | string[] | 5-color palette | Colors the projectiles cycle through. |
type | HTMLInputTypeAttribute | "email" | Input type. |
label | string | "Email address" | Accessible name for the field. |
onSubmit | (value: string) => void | - | Called with the trimmed value on submit. |
className | string | - | Forwarded to the root wrapper. |