Web design effects

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

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.

bin-eats-label.html
<!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

Seen in

抖音 @程序员八阿哥 · Delete Button — The Bin Eats The Label · 2026-09-08