Balloons

No dependencies
Code format
React + Tailwind
import { useId, useRef, useState, type CSSProperties } from 'react';

const MAX = 6;
const ADVANCE = 38;
const ANCHOR = { x: 131, y: 184 };
const TOP = 50; // where each string leaves its letter
const LIFT = 16; // how far below the top edge the letters float
const xOf = (n: number, i: number) => 131 + (i - (n - 1) / 2) * ADVANCE;
// Each string runs from under its letter down to the one weight they share.
const string = (x: number) => {
  const dx = ANCHOR.x - x;
  const dy = ANCHOR.y - TOP - LIFT;
  return { '--len': `${Math.hypot(dx, dy).toFixed(1)}px`, '--ang': `${((-Math.atan2(dx, dy) * 180) / Math.PI).toFixed(1)}deg` };
};

const css = `
  .field-balloons { --foil-hi: light-dark(#ebedf0, #f1f2f3); --foil: light-dark(#5f626a, #a7abb2); --foil-lo: light-dark(#42454c, #393c41); --edge: light-dark(#50535b, #7d8087); --ribbon: light-dark(#5f626a, #7d8087); position: relative; display: block; width: 262px; height: 204px; border-radius: 12px; cursor: text; }
  .field-balloons:has(.field-balloons__input:focus-visible) { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; }
  .field-balloons__input { position: absolute; inset: 0; z-index: 3; width: 100%; height: 100%; margin: 0; padding: 0; border: 0; background: transparent; color: transparent; caret-color: transparent; font-size: 16px; outline: none; cursor: text; text-transform: uppercase; }
  .field-balloons__input::selection { background: transparent; }
  /* A balloon: a foil letter bobbing on its own clock, string and all. */
  .field-balloons__balloon { position: absolute; left: 0; top: ${LIFT}px; width: 34px; margin-left: -17px; transform: translateX(var(--x)); transition: transform 0.7s cubic-bezier(0.25, 1.2, 0.5, 1); }
  .field-balloons__bob { position: relative; display: block; animation: field-balloons-bob var(--t) ease-in-out var(--d) infinite; transform-origin: 50% 100%; }
  @keyframes field-balloons-bob { 50% { transform: translateY(-5px) rotate(var(--sway)); } }
  .field-balloons__glyph { display: block; height: 52px; color: transparent; font: 900 46px/52px 'Arial Rounded MT Bold', ui-rounded, 'SF Pro Rounded', 'Arial Black', system-ui, sans-serif; text-align: center; background: linear-gradient(165deg, var(--foil-hi) 18%, var(--foil) 40%, var(--foil-hi) 52%, var(--foil-lo) 78%, var(--foil) 92%); background-clip: text; -webkit-background-clip: text; -webkit-text-stroke: 1.2px var(--edge); filter: drop-shadow(0 6px 5px light-dark(rgba(0, 0, 0, 0.09), rgba(0, 0, 0, 0.3))); }
  .field-balloons__balloon--new .field-balloons__glyph { animation: field-balloons-inflate 0.9s cubic-bezier(0.2, 0.8, 0.3, 1) both; }
  @keyframes field-balloons-inflate { 0% { transform: scale(0.2, 0.35) rotate(-18deg); opacity: 0.4; } 45% { transform: scale(1.14, 1.06) rotate(4deg); opacity: 1; } 65% { transform: scale(0.95, 1.02) rotate(-2deg); } 82% { transform: scale(1.03, 0.99); } 100% { transform: none; } }
  .field-balloons__string { position: absolute; left: 50%; top: ${TOP}px; width: 1px; height: var(--len); background: var(--ribbon); transform-origin: 50% 0; transform: rotate(var(--ang)); }
  .field-balloons__balloon--new .field-balloons__string { animation: field-balloons-tie 0.6s ease-out 0.2s both; }
  @keyframes field-balloons-tie { from { transform: rotate(var(--ang)) scaleY(0); } }
  /* Let go: it drifts up and away, string trailing. */
  .field-balloons__gone { position: absolute; left: 0; top: ${LIFT}px; width: 34px; margin-left: -17px; transform: translateX(var(--x)); pointer-events: none; animation: field-balloons-away 2.4s cubic-bezier(0.4, 0, 0.7, 0.5) forwards; }
  @keyframes field-balloons-away { 0% { transform: translateX(var(--x)); } 30% { transform: translate(calc(var(--x) + 6px), -50px) rotate(-8deg); } 100% { transform: translate(calc(var(--x) + 24px), -260px) rotate(14deg); opacity: 0; } }
  .field-balloons__gone .field-balloons__string { height: 60px; transform: rotate(6deg); }
  .field-balloons__weight { position: absolute; left: ${ANCHOR.x - 10}px; top: ${ANCHOR.y - 4}px; width: 20px; height: 16px; overflow: visible; fill: var(--ribbon); }
  .field-balloons__empty { position: absolute; left: 0; right: 0; top: 4px; color: transparent; font: 900 40px/52px 'Arial Rounded MT Bold', ui-rounded, 'SF Pro Rounded', 'Arial Black', system-ui, sans-serif; text-align: center; -webkit-text-stroke: 1px light-dark(#cbced3, #393c41); pointer-events: none; }
  .field-balloons__read { position: absolute; left: 0; right: 0; bottom: -2px; color: light-dark(#5f626a, #a7abb2); font: 500 11px system-ui, sans-serif; text-align: center; }
  @media (prefers-reduced-motion: reduce) {
    .field-balloons * { animation: none !important; transition: none !important; }
    .field-balloons__gone { display: none; }
  }
`;

export function Preview() {
  const id = useId();
  const [name, setName] = useState('NOAH');
  const [gone, setGone] = useState<{ ch: string; x: number; n: number }[]>([]);
  const born = useRef(name.length);
  const n = name.length;
  return (
    <>
      <style href="kit-inputs-balloons" precedence="kit">
        {css}
      </style>
      <label className="field-balloons" htmlFor={id}>
        {!n && <span className="field-balloons__empty" aria-hidden="true">NAME</span>}
        {[...name].map((ch, i) => (
          <span
            key={i}
            className={`field-balloons__balloon${i >= born.current ? ' field-balloons__balloon--new' : ''}`}
            style={{ '--x': `${xOf(n, i)}px`, ...string(xOf(n, i)) } as CSSProperties}
            aria-hidden="true"
          >
            <span className="field-balloons__bob" style={{ '--t': `${+(2.4 + (i % 3) * 0.45).toFixed(2)}s`, '--d': `${+(-i * 0.37).toFixed(2)}s`, '--sway': `${i % 2 ? 3 : -3}deg` } as CSSProperties}>
              <span className="field-balloons__string" />
              <span className="field-balloons__glyph">{ch}</span>
            </span>
          </span>
        ))}
        {gone.map((g) => (
          <span key={g.n} className="field-balloons__gone" style={{ '--x': `${g.x}px` } as CSSProperties} aria-hidden="true" onAnimationEnd={() => setGone((l) => l.filter((x) => x.n !== g.n))}>
            <span className="field-balloons__string" />
            <span className="field-balloons__glyph">{g.ch}</span>
          </span>
        ))}
        <svg className="field-balloons__weight" viewBox="0 0 20 16" aria-hidden="true">
          <path d="M10 4C6 0 1 1 2 4C3 7 8 5 10 4C12 5 17 7 18 4C19 1 14 0 10 4ZM6 8H14L16 16H4Z" />
        </svg>
        <input
          id={id}
          className="field-balloons__input"
          maxLength={MAX}
          autoComplete="given-name"
          spellCheck={false}
          aria-label="Name for the balloons"
          value={name}
          onChange={(e) => {
            const next = e.target.value.toUpperCase().replace(/[^A-Z0-9!&]/g, '').slice(0, MAX);
            born.current = Math.min(name.length, next.length);
            // Every letter taken off the end is let go, from where it was floating.
            if (next.length < name.length && name.startsWith(next)) {
              const freed = [...name.slice(next.length)].map((ch, k) => ({ ch, x: xOf(name.length, next.length + k), n: performance.now() + k }));
              setGone((l) => [...l, ...freed]);
            }
            setName(next);
          }}
        />
        <span className="field-balloons__read" aria-hidden="true">
          Delete a letter to let it go
        </span>
      </label>
    </>
  );
}

Free to use in any project, commercial included. MIT-0, no attribution required. Licence

Install with shadcn

npx shadcn@latest add @movin/input-balloons
Source on GitHub

About this component

A name in foil letter balloons on strings: each letter inflates with a wobble and bobs on its own clock, and a deleted letter is let go and floats away.

Dependencies
None
Difficulty
Intermediate
Triggers
state change, focus, spring, ambient
Techniques
keyframes, css variables, gradient, css transition
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

A native input named "Name for the balloons", transparent over the scene; focus outlines it. The balloons, strings and hint are aria-hidden. Under prefers-reduced-motion the balloons hang still and a deleted letter simply goes.

More inputs

See all
Beads

No photo yet · JPG, PNG or WebP

Flatbed
  • motion
  • ui kit
Freight
Growth