The bin eats the label
A delete button whose own label is the thing being deleted: the lid opens, the letters tumble in one at a time, and what is left is a circular icon with a progress ring around it.
How it works
- The label is split into one
<span>per glyph at runtime, and each span gets its own--fly(the horizontal distance from where that glyph sits to the bin's mouth, read once at click time) plus a--spinthat alternates direction. Six glyphs, 125ms apart, 300ms of flight each — that spacing is the whole character of the effect. Fire them together and it is a fade; space them further and the button feels broken. - The glyph transition eases in, not out:
cubic-bezier(0.55, 0, 0.85, 0.35). Things being sucked into something accelerate. Every other transition in this file eases out; this is the one that must not. - The bin opens before anything goes in. The lid rotates
-38degaround a hinge at its left end (transform-origin: 3px 6px) over 150ms, and the first glyph waitsLID + 30msbefore leaving. Without that beat the lid and the first letter move together and it reads as the label falling through a closed lid. - Collapsing the pill animates width only — height, padding and border-radius never move, and the icon is positioned at
left: (height − icon) / 2so it is already at the circle's centre before the width starts shrinking. The icon therefore does not travel at all, which is what sells "the label was eaten" over "the button resized". - The ring cannot live inside the button. The button needs
overflow: hiddento clip the glyphs mid-flight, and the ring sits 7px outside the button box — put it inside and it is clipped away entirely. It goes on a wrapping.slotinstead. Note what this failure looks like:stroke-dashoffsetanimates correctly the whole time, so every measurement passes while nothing renders. - The ring's
transition-delayequals the collapse duration, so the sweep starts after the circle has settled rather than during the shrink — two events instead of one blurry one. It runs linear: a progress indicator that eases is a progress indicator that lies about where it is. - The completed ring is held for 120ms before the button expands. Expanding on the frame the sweep lands means the full circle never renders once, and the whole sequence reads as having given up at 99%.
- The refuse level inside the bin is a
<rect>under aclipPath, scaled on Y one notch per glyph as each one lands. Each button needs its own clipPath id — two buttons in one document with the same id means the second silently uses the first's clip. .eatingstays on through the collapse and the ring, and comes off only when the button expands. Removing it once the eating is visually over — the obvious place — starts all six glyphs transitioning back toopacity: 1inside the circle, whereoverflow: hiddenhides the mistake and the state is quietly wrong for the entire ring phase.
Numbers
- Lid
- -38deg over 150ms, hinged at the bar's left end
- Glyph stagger
- 125ms · 6 glyphs = 750ms of eating
- Glyph flight
- 300ms cubic-bezier(0.55, 0, 0.85, 0.35) (ease-IN)
- Pill → circle
- 330ms cubic-bezier(0.22, 1, 0.36, 1)
- Progress ring
- 900ms linear, delayed by the collapse
- Ring hold
- 120ms at full before expanding
- Circle → pill
- 290ms, same ease
- Full cycle
- ~2.9s
Prompt
Paste this into any coding model to get the effect from scratch. It states every number explicitly — vague words like “smooth” or “modern” produce a different result every time.
Build a delete button in vanilla HTML/CSS/JS where the trash icon eats the label. Markup: a wrapper span.slot containing a button; the button holds an inline SVG bin (a separate .lid group and a .fill rect clipped by a clipPath) and a .label whose text 'Delete' is split into one span per character at runtime. The button is a 232x62px pill, border-radius 31px, overflow: hidden, and the bin is absolutely positioned at left: (62-26)/2 px so it already sits at the centre of the collapsed circle. On click: (1) rotate .lid -38deg with transform-origin at the left end of the lid bar over 150ms; (2) after 180ms, fly the glyphs into the bin one at a time, 125ms apart, each transitioning transform over 300ms with cubic-bezier(0.55, 0, 0.85, 0.35) — an ease-IN, because things being sucked in accelerate — to translate(var(--fly), 6px) rotate(var(--spin)) scale(0.55) with opacity going to 0 over 120ms after a 180ms delay; --fly is that glyph's horizontal distance to the bin mouth measured once at click time, --spin alternates sign and grows 170deg + 26deg per index; (3) scale the .fill rect up on Y one notch per glyph as each lands; (4) 180ms after the last glyph, collapse the button to a 62px circle by animating WIDTH ONLY over 330ms cubic-bezier(0.22, 1, 0.36, 1) — do not animate height, padding or border-radius; (5) show an SVG progress ring on the .slot wrapper (inset -7px, rotated -90deg, stroke-dasharray = circumference) and animate stroke-dashoffset to 0 over 900ms LINEAR with a transition-delay equal to the collapse duration; (6) hold the completed ring 120ms, then expand back to the pill over 290ms and restore the label in the same frame. The ring must be on the wrapper, not inside the button: the button's overflow: hidden would clip it away entirely while stroke-dashoffset still animates, so nothing renders and no measurement catches it. Give each button's clipPath a unique id. Keep a running flag so a second click mid-sequence is ignored. Under prefers-reduced-motion: reduce, set all transition durations to 1ms and drop the glyph transform so the label just goes, but keep the pill → circle → ring → pill state changes so the button still says work is happening.Source
The whole file. It is what the sample above is running — copy it into an .html file and it works with nothing else.
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>The bin eats the label</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
min-height: 100vh;
display: grid; place-items: center;
background: #f4eef7;
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", sans-serif;
overflow: hidden;
}
.stage { display: grid; justify-items: center; gap: 26px; }
.row { display: flex; gap: 34px; align-items: center; }
/* 🩸 The ring lives HERE, not inside the button. The button needs
`overflow: hidden` to clip the glyphs as they fly, and the ring sits
7px outside the button box — inside, it gets clipped away entirely.
Every numeric check still passes while that happens (the dashoffset
really is animating), so this one only shows up by looking.
Durations are declared here once. The button and the ring both live
inside the slot, so custom properties inherit down to both, and the JS
reads them from here too — otherwise the same 330 exists in three
places and someone changes one of them. */
.slot {
--h: 62px;
--pill: 232px;
--dur-collapse: 330ms;
--dur-expand: 290ms;
position: relative;
display: inline-flex;
justify-content: center;
/* 🩸 The slot holds the pill's full width even while the button inside it
is a 62px circle. Without this the flex row reflows on every click and
the *other* button slides 85px sideways — the collapse is supposed to
be a local event, not a layout event. */
width: var(--pill);
}
.hint { font-size: 12px; color: #6b5c78; letter-spacing: .01em; }
/* ── The button ──────────────────────────────────────────────────
Width is the only animated box property: the pill collapses to a
circle by shrinking `width` down to its own height. Height, radius
and padding never move, so the icon never shifts a pixel — which is
what makes the collapse read as "the label was eaten" rather than
"the button resized". */
.del {
/* Measured off the source clip: 12.7% of the duration covers 43% of the
distance, 25% covers 78%, 38% covers 89%. That is an easeOutQuint. */
--ease: cubic-bezier(0.22, 1, 0.36, 1);
position: relative;
height: var(--h);
width: var(--pill);
border: 0;
border-radius: calc(var(--h) / 2);
padding: 0;
cursor: pointer;
overflow: hidden;
display: flex; align-items: center;
background: linear-gradient(160deg, #8e33c4, #6c1f9c);
color: #fff;
font: 600 20px/1 inherit;
letter-spacing: .01em;
box-shadow: 0 10px 24px #6c1f9c33;
transition: width var(--dur-expand) var(--ease);
-webkit-tap-highlight-color: transparent;
}
.del.dark { background: linear-gradient(160deg, #3b3f4a, #23262d); box-shadow: 0 10px 24px #23262d33; }
.del:focus-visible { outline: 3px solid #ffffffcc; outline-offset: 3px; }
/* The icon is pinned to the left edge and centred in the collapsed
circle at the same time: left inset == (height − icon) / 2 means the
icon is already at the circle's centre before the width animates. */
.bin {
position: absolute;
left: calc((var(--h) - 26px) / 2);
width: 26px; height: 30px;
flex: none;
}
.bin svg { width: 100%; height: 100%; display: block; overflow: visible; }
.lid {
transform-origin: 3px 6px; /* hinge at the left end of the bar */
transition: transform 150ms var(--ease);
}
.del.eating .lid,
.del.busy .lid { transform: rotate(-38deg) translate(-1px, -2px); }
/* Refuse level: a clip rect that rises as letters land. */
.fill { transform-origin: 50% 100%; transform: scaleY(0); transition: transform 180ms linear; }
.label {
/* Sits to the right of the icon; each glyph is its own span so it can
fly independently. */
margin-left: var(--h);
padding-right: 22px;
white-space: nowrap;
display: flex;
}
.label span {
display: inline-block;
transition:
transform 300ms cubic-bezier(0.55, 0, 0.85, 0.35), /* ease-IN: the pull */
opacity 120ms linear 180ms;
}
/* Each glyph flies left into the open bin and tumbles on the way.
The horizontal distance is per-letter (set from JS) because letter 6
has much further to travel than letter 1. */
.del.eating .label span {
transform: translate(var(--fly), 6px) rotate(var(--spin)) scale(.55);
opacity: 0;
}
/* Progress ring: an SVG circle outside the collapsed button, revealed by
stroke-dashoffset. Only visible while .busy. */
/* 🩸 Fixed square, centred on the button — NOT `inset: -7px`. An <svg> with
insets on all four sides takes its height from its own 1:1 viewBox and the
*pill's* width, so in the resting state the ring silently becomes a
246×246 circle hanging 85px below the button. It is invisible there
(opacity 0) right up until the button expands, when the ring fades out
with no delay while growing back into that monster — which is exactly
when you see it. */
.ring {
position: absolute;
left: 50%; top: 50%;
width: calc(var(--h) + 14px);
height: calc(var(--h) + 14px);
pointer-events: none;
opacity: 0;
transform: translate(-50%, -50%) rotate(-90deg);
transition: opacity 120ms linear;
}
/* The ring waits out the collapse before it appears. Starting it while the
pill is still shrinking reads as one blurry event; letting the circle
settle first makes it "now it's working" instead. */
.slot:has(.busy) .ring { opacity: 1; transition-delay: var(--dur-collapse); }
.ring circle {
fill: none;
stroke: #d9a6f5;
stroke-width: 3;
stroke-linecap: round;
stroke-dasharray: var(--c);
stroke-dashoffset: var(--c);
}
.slot:has(.busy) .ring circle {
/* 900ms, linear — a progress ring that eases is a progress ring that lies. */
transition: stroke-dashoffset 900ms linear var(--dur-collapse);
stroke-dashoffset: 0;
}
.slot:has(.dark) .ring circle { stroke: #9aa3b2; }
.del.collapsed { width: var(--h); }
@media (prefers-reduced-motion: reduce) {
/* The whole point of the effect is the flight. With motion reduced the
label just goes; the state change (pill → circle → ring → back) still
happens so the button still communicates that work is running. */
.del, .label span, .lid, .fill, .ring circle { transition-duration: 1ms !important; }
.del.eating .label span { transform: none; }
}
</style>
<div class="stage">
<div class="row">
<span class="slot"><button class="del" type="button" aria-label="Delete"></button></span>
<span class="slot"><button class="del dark" type="button" aria-label="Delete"></button></span>
</div>
<p class="hint">Click a button</p>
</div>
<script>
const BIN = `
<svg viewBox="0 0 26 30" aria-hidden="true">
<g class="lid">
<rect x="1" y="4" width="24" height="3.2" rx="1.6" fill="#fff"/>
<rect x="9.5" y="0.6" width="7" height="3" rx="1.5" fill="#fff"/>
</g>
<g>
<clipPath id="c"><path d="M4.2 9.6h17.6l-1.7 18.2a2 2 0 0 1-2 1.8H7.9a2 2 0 0 1-2-1.8Z"/></clipPath>
<path d="M4.2 9.6h17.6l-1.7 18.2a2 2 0 0 1-2 1.8H7.9a2 2 0 0 1-2-1.8Z"
fill="none" stroke="#fff" stroke-width="2" stroke-linejoin="round"/>
<rect class="fill" clip-path="url(#c)" x="4" y="9" width="18" height="21" fill="#fff"/>
</g>
</svg>`;
const WORD = "Delete";
// All four numbers are measured off the source clip, not chosen:
// lid opens 1.63→1.72 · first glyph leaves 2.05 · last lands 2.80
// collapse 3.13→3.47 · ring 3.60→4.51 · expand 4.51→4.80
const LID = 150; // lid swings open (mirrors the 150ms in the CSS)
// 🩸 the bin must be OPEN before anything goes in. Without this beat the
// first glyph and the lid move together and it reads as the label
// falling through a closed lid.
const LEAD = LID + 30;
const STAGGER = 125; // six glyphs across 750ms
const FLIGHT = 300;
const EAT_TOTAL = LEAD + STAGGER * (WORD.length - 1) + FLIGHT; // 1105ms
const SETTLE = 180; // beat after the last glyph lands, before collapsing
// 从 CSS 读,不再手抄一份
const COLLAPSE = parseFloat(getComputedStyle(document.querySelector(".slot"))
.getPropertyValue("--dur-collapse"));
const RING = 900;
// 🩸 the completed ring has to be *seen*. Expanding on the same frame the
// sweep lands means the full circle never renders once, and the whole
// thing reads as "it gave up at 99%".
const HOLD = 120;
document.querySelectorAll(".del").forEach((btn) => {
const bin = document.createElement("span");
bin.className = "bin";
bin.innerHTML = BIN;
// Each button needs its own clipPath id or the second one silently
// inherits the first's — same document, duplicate ids.
const uid = "c" + Math.random().toString(36).slice(2, 8);
bin.querySelector("clipPath").id = uid;
bin.querySelector(".fill").setAttribute("clip-path", `url(#${uid})`);
const label = document.createElement("span");
label.className = "label";
label.setAttribute("aria-hidden", "true");
[...WORD].forEach((ch) => {
const s = document.createElement("span");
s.textContent = ch;
label.appendChild(s);
});
const ring = document.createElementNS("http://www.w3.org/2000/svg", "svg");
// 环随按钮一起缩:壳是 inline-flex,宽高跟着按钮走
ring.setAttribute("class", "ring");
ring.setAttribute("viewBox", "0 0 76 76");
const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
circle.setAttribute("cx", "38"); circle.setAttribute("cy", "38"); circle.setAttribute("r", "36");
const c = 2 * Math.PI * 36;
circle.style.setProperty("--c", c.toFixed(2));
ring.appendChild(circle);
btn.append(bin, label);
btn.parentElement.append(ring); // 壳上,绕开按钮的 overflow: hidden
const glyphs = [...label.children];
const fill = bin.querySelector(".fill");
let running = false;
btn.addEventListener("click", () => {
if (running) return;
running = true;
// Per-glyph flight distance: from where the glyph sits now to the
// bin's mouth. Read once, at click time — the layout is stable by then
// and reading it per frame would be the same number six times.
const mouth = bin.getBoundingClientRect();
glyphs.forEach((g, i) => {
const r = g.getBoundingClientRect();
g.style.setProperty("--fly", `${mouth.left + mouth.width / 2 - (r.left + r.width / 2)}px`);
g.style.setProperty("--spin", `${(i % 2 ? -1 : 1) * (170 + i * 26)}deg`);
const start = LEAD + i * STAGGER;
g.style.transitionDelay = `${start}ms, ${start + 180}ms`;
// Refuse level rises one notch per glyph, timed to when it lands.
setTimeout(() => { fill.style.transform = `scaleY(${(i + 1) / glyphs.length * 0.62})`; },
start + FLIGHT * 0.75);
});
btn.classList.add("eating");
setTimeout(() => {
btn.classList.add("collapsed");
btn.style.transitionDuration = "var(--dur-collapse)";
// 🩸 `.eating` stays on through the collapse and the ring. Dropping it
// here — the obvious place, since the eating is visually over —
// starts every glyph transitioning *back* to opacity 1 inside the
// circle. `overflow: hidden` happens to hide it, so nothing looks
// wrong; the state is just quietly false for the whole ring phase,
// and it only surfaces the day the button gets a wider collapsed
// size or the label loses its left margin.
btn.classList.add("busy");
}, EAT_TOTAL + SETTLE);
setTimeout(() => {
// Expand and restore the label in the same frame: the glyphs fly back
// out of the bin while the pill reopens.
btn.classList.remove("busy", "collapsed", "eating");
btn.style.transitionDuration = "";
fill.style.transform = "scaleY(0)";
glyphs.forEach((g) => { g.style.transitionDelay = ""; });
running = false;
// The ring's own transition-delay covers the collapse, so the total
// is collapse + ring, not collapse + delay + ring.
}, EAT_TOTAL + SETTLE + COLLAPSE + RING + HOLD);
});
});
</script>Where it breaks
- The label is
aria-hiddenand the accessible name comes fromaria-labelon the button — otherwise the name would flicker as glyphs leave the DOM's text content. - The letters fly to where the bin is at click time. If the button can reflow mid-sequence (a resize, a font swapping in), the destinations go stale and the glyphs land beside the bin instead of in it.
- This is a confirmation pattern, so it owes the user a way out. As built, the ring is decorative — it runs for a fixed 900ms and nothing can stop it. Wire it to the real request, and give it an undo: a ring that cannot be cancelled is a countdown that lies.
- Six glyphs is about the limit. "Delete permanently" at the same 125ms stagger would take 2.5 seconds just to eat, and the user is waiting on a destructive action the whole time.
- The ring is a fixed square centred on the button, not
inset: -7px. An<svg>with insets on all four sides takes its height from its own 1:1 viewBox and the pill's width, so at rest the ring quietly becomes a 246×246 circle hanging 85px below. It is invisible there — until the button expands, when the ring fades out with no delay while growing back into that shape, and you see a huge misplaced circle for about 120ms on the way out. - The
.slotkeeps the pill's full width while the button inside it is a circle. Without that, the flex row reflows on every click and the other button slides 85px sideways: collapsing one button is supposed to be a local event, not a layout event. - Yes, this animates
width, and that is a layout-animating property. The usual advice — usetransform— does not apply here:scaleXwould squash the bin icon and stretch the border-radius into an ellipse, and the whole point of the collapse is that neither moves. The cost is real but bounded: two elements, one 330ms run per click, nothing scroll-driven. Reach fortransformthe moment this pattern lands in a list where several can collapse at once.
Seen in
抖音 @程序员八阿哥 · Delete Button — The Bin Eats The Label · 2026-09-08