Introduction
In this article we build TrashBtn — an animated delete button with a fully CSS-driven trash icon, a paper-shredding keyframe animation, and a checkmark confirmation badge. Clicking the button triggers a multi-step sequence: the label slides out, the trash icon centers and scales up, the lid tines splay open, a paper note slides down through the bin, and finally a checkmark badge pops in to confirm the deletion. The entire visual effect is CSS — only the class toggle is JavaScript.
All visual states are driven by CSS custom properties declared on .button. Toggling the .delete class on the element redefines those variables, and CSS transitions animate between the old and new values automatically. This pattern keeps the component fully themeable — every timing, color, and distance is a token in a single :root-level block on the element itself.
How to drive multi-step animations entirely from CSS custom property overrides — how to build a trash icon from pure CSS pseudo-elements with no SVG or images — how to use @keyframes to animate a paper note sliding through a bin — how to reveal and animate a checkmark badge with staggered transition-delay — how to keep button state in a CSS class and reset it from JavaScript with setTimeout.
How the Animation Works
The button has two visual layers: the .trash icon wrapper on the left and the <span> label on the right. In the idle state the icon is scaled down to 0.64 and offset left, making it appear as a small decorative prefix. When .delete is added, --trash-x: 46px slides the icon into the horizontal center of the button while --span-x: 16px and --span-opacity: 0 simultaneously slide the label out to the right and fade it away.
Every animated value — position, scale, opacity, timing — is a CSS custom property. The .delete class does nothing except redefine those variables. CSS transitions do the rest.
The paper and cut animations are the only true @keyframes in the component. They cannot be driven by custom property transitions because they involve multi-stop motion (the paper appears, rises, then falls). Everything else — icon movement, label fade, tine rotation, checkmark pop — is a CSS transition on a custom property value change.
Step 1 — CSS Custom Properties
All color and state values are declared as custom properties directly on .button. This scopes the tokens to the component and avoids polluting the global :root. The state variables — --trash-x, --span-opacity, etc. — start at their idle defaults and are overridden when the .delete class is applied.
.button {
/* colors */
--background: #2b3044;
--background-hover: #1e2235;
--text: #fff;
--shadow: rgba(0, 9, 61, 0.2);
--paper: #5c86ff;
--paper-lines: #fff;
--trash: #e1e6f9;
--trash-lines: #bbc1e1;
--check: #fff;
--check-background: #5c86ff;
}Declaring the tokens on .button rather than :root means you can place multiple differently-themed buttons on the same page by simply overriding the variables on a second selector. There is no global namespace to manage — each button instance is self-contained. It also makes the component trivially portable: copy the HTML and CSS block and it works anywhere with zero conflicts.
Step 2 — The Markup
The button contains two direct children: .trash (the animated icon) and <span> (the label). Inside .trash there are three children: .trash__lid which holds the paper note and forms the lid bar and tines via pseudo-elements, .trash__bin which is the body of the can, and .trash__check which is the confirmation badge that appears at the end of the sequence.
<button class="button" type="button" aria-label="Delete item">
<div class="trash" aria-hidden="true">
<div class="trash__lid">
<!-- paper note that animates down into the bin -->
<div class="trash__paper"></div>
</div>
<!-- bin body — ridges and paper strip via ::before/::after -->
<div class="trash__bin"></div>
<!-- success badge shown after deletion -->
<div class="trash__check">
<svg viewBox="0 0 8 6">
<polyline points="1 3.4 2.71428571 5 7 1"></polyline>
</svg>
</div>
</div>
<span class="button__label">Delete Item</span>
</button>| Element | Role | Animated |
|---|---|---|
| .button | Shell — holds all tokens and transitions | Yes — scale, box-shadow, background |
| .trash | Icon wrapper — translates and scales on delete | Yes — translate + scale via custom props |
| .trash::before/after | Lid tines — rotate outward when delete triggers | Yes — rotate 40deg on delete |
| .trash__lid | Lid area — overflow:hidden container for paper | No |
| .trash__lid::before | Handle nub above the lid bar | No |
| .trash__lid::after | Lid bar — scaleX to 0 on delete | Yes — scaleX transition |
| .trash__paper | Paper note — keyframe animation drops it through the bin | Yes — @keyframes paper |
| .trash__bin | Bin body — border box with ridges and paper strip inside | No |
| .trash__bin::before | Vertical ridges — fade out on delete | Yes — opacity transition |
| .trash__bin::after | Paper strip inside bin — @keyframes cut slides it through | Yes — @keyframes cut |
| .trash__check | Checkmark badge — pops in with scale + opacity after 1.7s | Yes — scale + opacity + stroke-dashoffset |
| .button__label | Text label — slides right and fades out on delete | Yes — translateX + opacity |
Step 3 — Building the Trash Icon from CSS
The trash icon is built entirely from borders, pseudo-elements, and box-shadows — no SVG, no images. The bin body is .trash__bin: a 20×25px div with a 2px border and border-radius: 1px 1px 4px 4px. The vertical ridges inside the bin are its ::before pseudo-element, using box-shadow to duplicate the strip: box-shadow: 10px 0 0 var(--trash-lines) creates a second ridge 10px to the right of the first with a single declaration.
/* Bin body */
.trash__bin {
width: 20px;
height: 25px;
border: 2px solid var(--icon, var(--trash));
border-radius: 1px 1px 4px 4px;
position: relative;
overflow: hidden;
z-index: 2;
transition: border-color 0.3s;
}
/* Vertical ridges — duplicated via box-shadow */
.trash__bin::before {
content: '';
position: absolute;
top: 0;
left: 50%;
width: 4px;
height: 20px;
border-radius: 2px;
margin-left: -2px;
background: var(--trash-lines);
transform: translateX(-3px) scale(0.6);
box-shadow: 10px 0 0 var(--trash-lines);
opacity: var(--trash-lines-opacity, 1);
transition: transform 0.4s, opacity 0.4s;
}
/* Lid tines — rotate outward on delete */
.trash::before,
.trash::after {
content: '';
position: absolute;
bottom: 100%;
width: 2px;
height: 8px;
border-radius: 1px;
background: var(--icon, var(--trash));
transform-origin: 50% 6px;
transform: translate(var(--x, 3px), 2px) scaleY(var(--sy, 0.7)) rotate(var(--r, 0deg));
transition: transform 0.4s, background 0.3s;
}
.trash::before { left: 1px; }
.trash::after { right: 1px; --x: -3px; }CSS box-shadow accepts multiple comma-separated shadows and they can have zero blur — making them crisp copies of the element at an offset. This is a useful trick for repeating shapes (ridges, dots, lines) without extra DOM elements. The ridge element is one <pseudo-element> producing two visual strips: itself and its shadow at 10px offset.
Step 4 — The Paper & Cut Keyframes
Two @keyframes sequences drive the paper shredding effect. The paper animation runs on .trash__paper — the small blue note above the bin lid. It starts hidden (opacity: 0), fades in, bounces up slightly, then falls down through the lid into the bin (translateY(24px)). The cut animation runs on .trash__bin::after — the paper strip inside the bin — sliding it down to simulate the paper passing through.
@keyframes paper {
10%, 100% { opacity: 1; }
20% { transform: translateY(-16px); }
40% { transform: translateY(0); }
70%, 100% { transform: translateY(24px); }
}
@keyframes cut {
0%, 40% { transform: translate(-0.5px, -16px) scaleX(0.5); }
100% { transform: translate(-0.5px, 24px) scaleX(0.5); }
}
/* Applied on .delete */
.button.delete .trash__paper { animation: paper 1.5s linear forwards 0.5s; }
.button.delete .trash__bin::after { animation: cut 1.5s linear forwards 0.5s; }Both animations use animation-fill-mode: forwards so the paper stays in its final fallen position and does not snap back to the start when the animation ends.
Step 5 — The Confirmation Checkmark
The checkmark badge is an absolutely-positioned circle that sits below the bin at top: 24px. In the idle state it is invisible (--check-opacity: 0) and tiny (--check-scale: 0.2). When .delete is added, its opacity and scale animate in — but with --check-delay: 1.7s, so it only appears after the paper animation has completed. The SVG checkmark stroke draws itself via stroke-dashoffset going from 9px to 0 at --checkmark-delay: 2.1s.
.trash__check {
padding: 4px 3px;
border-radius: 50%;
background: var(--check-background);
position: absolute;
left: 2px;
top: 24px;
opacity: var(--check-opacity, 0);
transform: translateY(var(--check-y, 0)) scale(var(--check-scale, 0.2));
transition:
transform var(--check-duration, 0.2s) ease var(--check-delay, 0s),
opacity var(--check-duration-opacity, 0.2s) ease var(--check-delay, 0s);
}
.trash__check svg {
display: block;
width: 8px;
height: 6px;
fill: none;
stroke: var(--check);
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 9px;
stroke-dashoffset: var(--check-offset, 9px);
transition: stroke-dashoffset 0.4s ease var(--checkmark-delay, 0.4s);
}
/* Delete state — badge animates in at 1.7s */
.button.delete {
--check-offset: 0;
--check-opacity: 1;
--check-scale: 1;
--check-y: 16px;
--check-delay: 1.7s;
--checkmark-delay: 2.1s;
--check-duration: 0.55s;
--check-duration-opacity: 0.3s;
}Setting stroke-dasharray equal to the path length makes the entire stroke one dash. Setting stroke-dashoffset to the same value hides it completely — the dash is shifted off-screen. Animating stroke-dashoffset to 0 slides the dash into view, making the stroke appear to draw itself. This works for any SVG path: measure the path length with path.getTotalLength() and use that value for both properties.
Step 6 — The .delete Class Overrides
All animation state lives in the .button.delete rule block. It overrides the custom property defaults set on .button, which triggers every transition simultaneously. The label slides out, the icon translates to center, the tines rotate, and the staggered delays on the check badge ensure the confirmation only appears after the trash animation finishes.
.button.delete {
/* label */
--span-opacity: 0;
--span-x: 16px;
--span-delay: 0s;
/* icon position */
--trash-x: 46px;
--trash-y: 2px;
--trash-scale: 1;
/* lid */
--trash-lines-opacity: 0;
--trash-line-scale: 0;
--icon: #fff;
/* checkmark */
--check-offset: 0;
--check-opacity: 1;
--check-scale: 1;
--check-y: 16px;
--check-delay: 1.7s;
--checkmark-delay: 2.1s;
--check-duration: 0.55s;
--check-duration-opacity: 0.3s;
}
/* Tines splay open */
.button.delete .trash::before { --sy: 1; --x: 0; --r: 40deg; }
.button.delete .trash::after { --sy: 1; --x: 0; --r: -40deg; }Step 7 — The Class Toggle
The only JavaScript in the component is a click listener that adds .delete and removes it after 3200ms — long enough for the full animation sequence (paper: 0.5s delay + 1.5s duration = 2s, checkmark: 2.1s delay + 0.4s draw = ~2.5s, with margin). A guard clause prevents re-triggering while the animation is already running.
const ANIMATION_DURATION = 3200;
document.querySelectorAll('.button').forEach((button) => {
button.addEventListener('click', (e) => {
e.preventDefault();
if (button.classList.contains('delete')) return;
button.classList.add('delete');
setTimeout(() => button.classList.remove('delete'), ANIMATION_DURATION);
});
});The last animation event is the checkmark stroke drawing at --checkmark-delay: 2.1s + 0.4s transition = 2.5s total. Adding ~700ms of breathing room gives 3200ms. If you shorten any of the delays in the CSS, reduce this value accordingly. If you lengthen them, increase it — otherwise the class will be removed before the animation finishes and the state will snap back mid-sequence.
Tuning Reference
| Token / Property | Default | Effect |
|---|---|---|
| --trash-x | 46px | How far the icon slides toward center on delete. Increase for wider buttons |
| --trash-scale | 0.64 | Idle icon scale. Increase to make the icon larger at rest |
| --span-x | 16px | How far the label slides out to the right on delete |
| --check-delay | 1.7s | When the checkmark badge starts appearing. Should be after the paper animation ends |
| --checkmark-delay | 2.1s | When the SVG stroke starts drawing. Should be after the badge has scaled in |
| --check-duration | 0.55s | How long the badge scale-in takes |
| animation-delay (paper/cut) | 0.5s | Delay before paper starts falling. Gives time for tines to splay open first |
| paper duration | 1.5s | Total duration of the paper falling animation |
| ANIMATION_DURATION (JS) | 3200ms | How long before .delete is removed. Must exceed the last CSS animation end time |
| --background | #2b3044 | Idle button background color |
| --background-hover | #1e2235 | Hovered and active-delete button background |
| --paper | #5c86ff | Color of the paper note and paper strip inside bin |
| --check-background | #5c86ff | Background color of the checkmark badge circle |
Full Source Code
Save the following as delete-button.html and open in any modern browser. The only dependency is a Google Fonts import for Inter — remove it and add font-family: sans-serif to use system fonts instead.
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/delete-button.htmlThe label, the trash icon, the paper animation, and the checkmark — all driven by a single class toggle and zero CSS frameworks.