Balloons
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>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initFieldBalloons(el) on the mounted element instead.
<label class="field-balloons" for="field-balloons">
<span class="field-balloons__balloon" style="--x:74px;--len:131.0px;--ang:-25.8deg" aria-hidden="true">
<span class="field-balloons__bob" style="--t:2.4s;--d:0s;--sway:-3deg">
<span class="field-balloons__string"></span>
<span class="field-balloons__glyph">N</span>
</span>
</span>
<span class="field-balloons__balloon" style="--x:112px;--len:119.5px;--ang:-9.1deg" aria-hidden="true">
<span class="field-balloons__bob" style="--t:2.85s;--d:-0.37s;--sway:3deg">
<span class="field-balloons__string"></span>
<span class="field-balloons__glyph">O</span>
</span>
</span>
<span class="field-balloons__balloon" style="--x:150px;--len:119.5px;--ang:9.1deg" aria-hidden="true">
<span class="field-balloons__bob" style="--t:3.3s;--d:-0.74s;--sway:-3deg">
<span class="field-balloons__string"></span>
<span class="field-balloons__glyph">A</span>
</span>
</span>
<span class="field-balloons__balloon" style="--x:188px;--len:131.0px;--ang:25.8deg" aria-hidden="true">
<span class="field-balloons__bob" style="--t:2.4s;--d:-1.11s;--sway:3deg">
<span class="field-balloons__string"></span>
<span class="field-balloons__glyph">H</span>
</span>
</span>
<svg class="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"></path>
</svg>
<input id="field-balloons" class="field-balloons__input" maxLength="6" autoComplete="given-name" spellCheck="false" aria-label="Name for the balloons" value="NOAH"/>
<span class="field-balloons__read" aria-hidden="true">Delete a letter to let it go</span>
</label>.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: 16px; 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: 50px; 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: 16px; 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: 121px; top: 180px; 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; }
}function initFieldBalloons(root) {
var MAX = 6;
var ADVANCE = 38;
var ANCHOR = { x: 131, y: 184 };
var TOP = 50; // where each string leaves its letter
var LIFT = 16; // how far below the top edge the letters float
var weight = root.querySelector('.field-balloons__weight');
var input = root.querySelector('.field-balloons__input');
var balloons = [].slice.call(root.querySelectorAll('.field-balloons__balloon'));
var name = input.value;
var empty = document.createElement('span');
empty.className = 'field-balloons__empty';
empty.setAttribute('aria-hidden', 'true');
empty.textContent = 'NAME';
function xOf(n, i) {
return 131 + (i - (n - 1) / 2) * ADVANCE;
}
// Each string runs from under its letter down to the one weight they share.
function tie(el, x) {
var dx = ANCHOR.x - x;
var dy = ANCHOR.y - TOP - LIFT;
el.style.setProperty('--x', x + 'px');
el.style.setProperty('--len', Math.hypot(dx, dy).toFixed(1) + 'px');
el.style.setProperty('--ang', ((-Math.atan2(dx, dy) * 180) / Math.PI).toFixed(1) + 'deg');
}
function balloon(i) {
var el = document.createElement('span');
el.setAttribute('aria-hidden', 'true');
el.innerHTML = '<span class="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"><span class="field-balloons__string"></span><span class="field-balloons__glyph"></span></span>';
return el;
}
function render(born) {
var n = name.length;
while (balloons.length > n) balloons.pop().remove();
for (var i = 0; i < n; i++) {
if (!balloons[i]) root.insertBefore((balloons[i] = balloon(i)), root.querySelector('.field-balloons__gone') || weight);
// Only a letter just added inflates; the rest drift over to make room.
balloons[i].className = 'field-balloons__balloon' + (i >= born ? ' field-balloons__balloon--new' : '');
balloons[i].querySelector('.field-balloons__glyph').textContent = name[i];
tie(balloons[i], xOf(n, i));
}
if (n) empty.remove();
else root.insertBefore(empty, root.firstChild);
}
function onInput() {
var next = input.value.toUpperCase().replace(/[^A-Z0-9!&]/g, '').slice(0, MAX);
input.value = next;
// Every letter taken off the end is let go, from where it was floating.
if (next.length < name.length && name.indexOf(next) === 0 && !matchMedia('(prefers-reduced-motion: reduce)').matches) {
for (var k = next.length; k < name.length; k++) {
var g = document.createElement('span');
g.className = 'field-balloons__gone';
g.setAttribute('aria-hidden', 'true');
g.style.setProperty('--x', xOf(name.length, k) + 'px');
g.innerHTML = '<span class="field-balloons__string"></span><span class="field-balloons__glyph"></span>';
g.lastChild.textContent = name[k];
g.addEventListener('animationend', function (e) {
if (e.target === e.currentTarget) e.currentTarget.remove();
});
root.insertBefore(g, weight);
}
}
var born = Math.min(name.length, next.length);
name = next;
render(born);
}
input.addEventListener('input', onInput);
return function () {
input.removeEventListener('input', onInput);
};
}
document.querySelectorAll('.field-balloons').forEach(initFieldBalloons);Goal: Create a "Balloons" input.
Visual behavior: 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.
Technique:
- A real text input lies transparent over the scene; the balloons are drawn from its value, uppercased, up to six characters.
- Each letter is foil: a gradient clipped to the glyph with a thin stroke, and a drop shadow. A new letter inflates from squashed and tilted through an overshoot to rest.
- Every balloon bobs and sways on its own period and phase, and its string runs from under it to the shared weight with a length and angle computed per position.
- A deleted letter becomes a copy at its spot that drifts up and off with its string trailing, while the rest close ranks.
Accessibility: 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.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Balloons" input.
Visual behavior: 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.
Technique:
- A real text input lies transparent over the scene; the balloons are drawn from its value, uppercased, up to six characters.
- Each letter is foil: a gradient clipped to the glyph with a thin stroke, and a drop shadow. A new letter inflates from squashed and tilted through an overshoot to rest.
- Every balloon bobs and sways on its own period and phase, and its string runs from under it to the shared weight with a length and angle computed per position.
- A deleted letter becomes a copy at its spot that drifts up and off with its string trailing, while the rest close ranks.
Accessibility: 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.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
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.