liten

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.

Terminal
bun add motion
components/ui/exploding-input.tsx
'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

Example.tsx
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

PropTypeDefaultDescription
defaultValuestring""Uncontrolled initial value.
placeholderstring"you@company.com"Input placeholder.
buttonLabelstring"Join"Label on the submit button.
accentstring"#f0883e"Button fill and focus outline color.
iconsLucideIcon[]8 playful glyphsThe icons that shoot out.
colorsstring[]5-color paletteColors the projectiles cycle through.
typeHTMLInputTypeAttribute"email"Input type.
labelstring"Email address"Accessible name for the field.
onSubmit(value: string) => void-Called with the trimmed value on submit.
classNamestring-Forwarded to the root wrapper.
On this page0%