Gravity
import { useRef, useState } from 'react';
const css = `
@keyframes field-gravity-fall {
from { transform: translate(0, 0) rotate(0deg); opacity: 1; }
to { transform: translate(var(--dx), 86px) rotate(var(--rot)); opacity: 0; }
}
.field-gravity { position: relative; width: 250px; padding-bottom: 70px; }
.field-gravity__label { display: block; margin-bottom: 6px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-gravity__box { position: relative; }
.field-gravity__input {
width: 100%;
height: 42px;
padding: 0 12px;
border: 0;
outline: 0;
border-radius: 10px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
color: light-dark(#373a40, #f1f2f3);
font-family: inherit; font-size: 15px;
transition: box-shadow 0.2s;
}
.field-gravity__input:focus { box-shadow: inset 0 0 0 1px light-dark(#5f626a, #a7abb2); }
.field-gravity__drop {
position: absolute;
top: 11px;
color: light-dark(#42454c, #e5e6e9);
font-family: inherit; font-size: 15px;
white-space: pre;
pointer-events: none;
animation: field-gravity-fall 0.95s cubic-bezier(0.45, 0, 0.9, 0.55) forwards;
}
@media (prefers-reduced-motion: reduce) { .field-gravity__drop { display: none; } }
.field-gravity__top { display: flex; align-items: center; gap: 5px; margin-bottom: 6px; }
.field-gravity__top > :first-child { margin: 0; }
.field-gravity__info { position: relative; display: grid; place-items: center; width: 16px; height: 16px; padding: 0; border: 0; border-radius: 50%; background: none; color: light-dark(#5f626a, #a7abb2); cursor: help; }
.field-gravity__info svg { width: 14px; height: 14px; }
.field-gravity__info:hover { color: light-dark(#42454c, #e5e6e9); }
.field-gravity__info:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 1px; }
/* How-to lives behind the icon, so the field itself stays minimal. */
.field-gravity__info::after {
content: attr(data-tip);
position: absolute;
left: -6px;
bottom: calc(100% + 6px);
z-index: 5;
padding: 4px 8px;
border-radius: 6px;
background: light-dark(#373a40, #f1f2f3);
color: light-dark(#fbfcfd, #121417);
font-family: inherit; font-size: 11px; font-weight: 500; line-height: 1.3;
width: max-content;
max-width: 150px;
opacity: 0;
transform: translateY(3px);
pointer-events: none;
transition: opacity 0.15s, transform 0.15s;
}
.field-gravity__info:hover::after, .field-gravity__info:focus-visible::after { opacity: 1; transform: none; }
`;
type Drop = { id: number; ch: string; x: number; dx: number; rot: number; delay: number };
const PAD = 12;
export function GravityInput() {
const input = useRef<HTMLInputElement>(null);
const [value, setValue] = useState('Backspace sends these tumbling');
const [drops, setDrops] = useState<Drop[]>([]);
const nextId = useRef(0);
const measure = useRef<CanvasRenderingContext2D | null>(null);
const change = (next: string) => {
if (next.length < value.length) {
// Find what was removed, and where each removed letter sat on screen.
let p = 0;
while (p < next.length && value[p] === next[p]) p++;
const removed = value.slice(p, p + value.length - next.length);
const el = input.current!;
measure.current ??= document.createElement('canvas').getContext('2d');
const ctx = measure.current!;
ctx.font = getComputedStyle(el).font;
const born = [...removed].map((ch, i) => {
const id = nextId.current++;
const r = Math.sin(id * 78.233) * 43758.5;
const f = r - Math.floor(r);
return { id, ch, x: PAD + ctx.measureText(value.slice(0, p + i)).width - el.scrollLeft, dx: (f - 0.5) * 50, rot: (f - 0.5) * 300, delay: i * 25 };
});
setDrops((d) => [...d, ...born]);
window.setTimeout(() => setDrops((d) => d.filter((x) => !born.includes(x))), 1100 + born.length * 25);
}
setValue(next);
};
return (
<>
<style>
{css}
</style>
<div className="field-gravity">
<div className="field-gravity__top">
<label className="field-gravity__label" htmlFor="field-gravity-input">
Caption
</label>
<button type="button" className="field-gravity__info" aria-label="Delete text to drop it" data-tip="Delete text to drop it">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<circle cx="12" cy="12" r="9" />
<path d="M12 11v5M12 7.5v.01" />
</svg>
</button>
</div>
<div className="field-gravity__box">
<input
ref={input}
id="field-gravity-input"
className="field-gravity__input"
value={value}
spellCheck={false}
autoComplete="off"
onChange={(e) => change(e.target.value)}
/>
{drops.map((d) => (
<span
key={d.id}
className="field-gravity__drop"
aria-hidden="true"
style={{ left: d.x, animationDelay: `${d.delay}ms`, '--dx': `${d.dx.toFixed(0)}px`, '--rot': `${d.rot.toFixed(0)}deg` } as React.CSSProperties}
>
{d.ch}
</span>
))}
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initFieldGravity(el) on the mounted element instead.
<div class="field-gravity">
<div class="field-gravity__top">
<label class="field-gravity__label" for="field-gravity-input">Caption</label>
<button type="button" class="field-gravity__info" aria-label="Delete text to drop it" data-tip="Delete text to drop it">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<circle cx="12" cy="12" r="9"></circle>
<path d="M12 11v5M12 7.5v.01"></path>
</svg>
</button>
</div>
<div class="field-gravity__box">
<input id="field-gravity-input" class="field-gravity__input" spellCheck="false" autoComplete="off" value="Backspace sends these tumbling"/>
</div>
</div>@keyframes field-gravity-fall {
from { transform: translate(0, 0) rotate(0deg); opacity: 1; }
to { transform: translate(var(--dx), 86px) rotate(var(--rot)); opacity: 0; }
}
.field-gravity { position: relative; width: 250px; padding-bottom: 70px; }
.field-gravity__label { display: block; margin-bottom: 6px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-gravity__box { position: relative; }
.field-gravity__input {
width: 100%;
height: 42px;
padding: 0 12px;
border: 0;
outline: 0;
border-radius: 10px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
color: light-dark(#373a40, #f1f2f3);
font-family: inherit; font-size: 15px;
transition: box-shadow 0.2s;
}
.field-gravity__input:focus { box-shadow: inset 0 0 0 1px light-dark(#5f626a, #a7abb2); }
.field-gravity__drop {
position: absolute;
top: 11px;
color: light-dark(#42454c, #e5e6e9);
font-family: inherit; font-size: 15px;
white-space: pre;
pointer-events: none;
animation: field-gravity-fall 0.95s cubic-bezier(0.45, 0, 0.9, 0.55) forwards;
}
@media (prefers-reduced-motion: reduce) { .field-gravity__drop { display: none; } }
.field-gravity__top { display: flex; align-items: center; gap: 5px; margin-bottom: 6px; }
.field-gravity__top > :first-child { margin: 0; }
.field-gravity__info { position: relative; display: grid; place-items: center; width: 16px; height: 16px; padding: 0; border: 0; border-radius: 50%; background: none; color: light-dark(#5f626a, #a7abb2); cursor: help; }
.field-gravity__info svg { width: 14px; height: 14px; }
.field-gravity__info:hover { color: light-dark(#42454c, #e5e6e9); }
.field-gravity__info:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 1px; }
/* How-to lives behind the icon, so the field itself stays minimal. */
.field-gravity__info::after {
content: attr(data-tip);
position: absolute;
left: -6px;
bottom: calc(100% + 6px);
z-index: 5;
padding: 4px 8px;
border-radius: 6px;
background: light-dark(#373a40, #f1f2f3);
color: light-dark(#fbfcfd, #121417);
font-family: inherit; font-size: 11px; font-weight: 500; line-height: 1.3;
width: max-content;
max-width: 150px;
opacity: 0;
transform: translateY(3px);
pointer-events: none;
transition: opacity 0.15s, transform 0.15s;
}
.field-gravity__info:hover::after, .field-gravity__info:focus-visible::after { opacity: 1; transform: none; }function initFieldGravity(root) {
var input = root.querySelector('.field-gravity__input');
var box = root.querySelector('.field-gravity__box');
if (!input || !box) return;
var PAD = 12;
var ctx = document.createElement('canvas').getContext('2d');
var value = input.value;
var id = 0;
function change() {
var next = input.value;
if (next.length < value.length && !matchMedia('(prefers-reduced-motion: reduce)').matches) {
// Find what was removed, and where each removed letter sat on screen.
var p = 0;
while (p < next.length && value[p] === next[p]) p++;
var removed = value.slice(p, p + value.length - next.length);
ctx.font = getComputedStyle(input).font;
removed.split('').forEach(function (c, i) {
id += 1;
var r = Math.sin(id * 78.233) * 43758.5;
var f = r - Math.floor(r);
var drop = document.createElement('span');
drop.className = 'field-gravity__drop';
drop.setAttribute('aria-hidden', 'true');
drop.textContent = c;
drop.style.left = PAD + ctx.measureText(value.slice(0, p + i)).width - input.scrollLeft + 'px';
drop.style.animationDelay = i * 25 + 'ms';
drop.style.setProperty('--dx', ((f - 0.5) * 50).toFixed(0) + 'px');
drop.style.setProperty('--rot', ((f - 0.5) * 300).toFixed(0) + 'deg');
drop.addEventListener('animationend', function () { drop.remove(); });
box.appendChild(drop);
});
}
value = next;
}
input.addEventListener('input', change);
return function () { input.removeEventListener('input', change); };
}
document.querySelectorAll('.field-gravity').forEach(initFieldGravity);Goal: Create a "Gravity" input.
Visual behavior: A caption field where deleted text does not just vanish: every letter you delete drops out of the field and tumbles away with its own spin and drift, fading as it falls.
Technique:
- On each edit the removed run is found by comparing the old and new values from the front.
- Each removed letter's x position is measured with a canvas 2D context set to the input's computed font (measureText of the text before it), minus the input's scrollLeft.
- A span for each letter falls 86px on an accelerating curve with a random drift and spin in CSS variables, staggered 25ms apart, and is removed after it fades. The how-to sits behind a small info button beside the label (its aria-label carries the same text), so the field itself stays minimal.
Accessibility: A real <input> with a visible <label>; decoration is aria-hidden. The falling letters are aria-hidden. Under prefers-reduced-motion nothing falls.
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 "Gravity" input.
Visual behavior: A caption field where deleted text does not just vanish: every letter you delete drops out of the field and tumbles away with its own spin and drift, fading as it falls.
Technique:
- On each edit the removed run is found by comparing the old and new values from the front.
- Each removed letter's x position is measured with a canvas 2D context set to the input's computed font (measureText of the text before it), minus the input's scrollLeft.
- A span for each letter falls 86px on an accelerating curve with a random drift and spin in CSS variables, staggered 25ms apart, and is removed after it fades. The how-to sits behind a small info button beside the label (its aria-label carries the same text), so the field itself stays minimal.
Accessibility: A real <input> with a visible <label>; decoration is aria-hidden. The falling letters are aria-hidden. Under prefers-reduced-motion nothing falls.
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 caption field where deleted text does not just vanish: every letter you delete drops out of the field and tumbles away with its own spin and drift, fading as it falls.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- focus, state change, delay stagger
- Techniques
- keyframes, css variables, event position
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A real <input> with a visible <label>; decoration is aria-hidden. The falling letters are aria-hidden. Under prefers-reduced-motion nothing falls.