Introduction
This component is a vertical stack of four cartoon-style buttons that drop into place one after another, then squish like gumdrops on hover. There is no library behind the motion and no JavaScript at all — the whole effect comes from a list of links, one flat offset shadow standing in for an outline, and two keyframe animations doing very different jobs: one plays once on load, the other plays once on every hover.
The technique rests on four ideas: a box-shadow with zero blur and a fixed pixel offset reads as a solid drawn outline rather than a soft shadow, which is what gives the buttons their sticker-like edge — animation-delay values set per :nth-child turn a single shared keyframe into a staggered cascade without writing four separate animations — a squish keyframe that scales unevenly on the x and y axes at the same time (wide and short, then back to normal) is what sells "squash," while a slight rotation sells "wobble" — and pulling the interactive color pair onto CSS custom properties per button is what lets four buttons share one rule instead of four near-duplicate ones.
How a zero-blur box-shadow doubles as a hand-drawn outline — how animation-delay on nth-child turns one keyframe into a staggered list entrance — how scaling unevenly on x and y in a single keyframe fakes squash-and-stretch without a physics library — how to layer a hover squish on top of a load-in animation without the two fighting each other — and how to make the effect keyboard-accessible and motion-safe without changing how it looks for anyone else.
Step 1 — Markup That Repeats on Purpose
Four buttons that behave identically belong in a list, not four separately styled anchors. Each <li> owns the entrance animation and its stagger delay; each nested <a class="btn"> owns the click target and hover animation; the inner <span> is what actually gets the background, padding, and shadow. Splitting the animated wrapper from the styled span matters later — it lets the entrance transform and the hover squish transform live on two different elements, so they never have to fight over the same transform property at the same time.
<ul class="btns">
<li><a class="btn" href="#"><span>Mango</span></a></li>
<li><a class="btn" href="#"><span>Peach</span></a></li>
<li><a class="btn" href="#"><span>Guava</span></a></li>
<li><a class="btn" href="#"><span>Lychee</span></a></li>
</ul>Step 2 — Zero Blur Turns a Shadow into a Drawn Line
A typical drop shadow has blur and low opacity so it reads as ambient depth. Setting the blur radius to 0 and the color to a fully opaque near-black does the opposite — it produces a crisp duplicate of the button's silhouette, offset a fixed distance down and to the right. That flat offset is what makes these read as sticker or comic-panel buttons rather than soft material-design cards, and it costs one line instead of an extra border element.
.btn span {
padding: 1em;
background: var(--btn-color);
color: #fff8f2;
border-radius: 20px;
box-shadow: 4px 4px 0 #1a1026; /* zero blur = a drawn outline, not a shadow */
}Drop the blur radius to zero and a box-shadow stops looking like light and starts looking like ink.
Step 3 — One Keyframe, Four Delays
Every button runs the exact same entrance animation — fade in while sliding up from 50px below. What makes them arrive one after another instead of all at once is nothing but animation-delay, set per position with :nth-child. The animation itself never changes; only when it starts does.
.btn {
opacity: 0;
animation: entrance 0.85s cubic-bezier(0.68, -0.55, 0.27, 1.55) forwards;
}
.btns li:nth-child(1) .btn { animation-delay: 0s; }
.btns li:nth-child(2) .btn { animation-delay: 0.1s; }
.btns li:nth-child(3) .btn { animation-delay: 0.2s; }
.btns li:nth-child(4) .btn { animation-delay: 0.3s; }
@keyframes entrance {
0% { opacity: 0; transform: translateY(50px); }
100% { opacity: 1; transform: translateY(0); }
}The easing curve matters as much as the delay. cubic-bezier(0.68, -0.55, 0.27, 1.55) dips below 0 and rises above 1, which means the button briefly overshoots its resting position before settling — a small bounce on arrival that a linear or ease-out curve cannot produce. animation-fill-mode: forwards (via the forwards keyword) is what keeps each button at opacity: 1 after its animation finishes instead of snapping back to its opacity: 0 starting state.
Step 4 — Two Steps Are Enough for a Squish
On hover, the button's font grows, its color swaps, and an inset ring appears — all straightforward transitions. The squish itself is a separate, short keyframe animation layered on top, and it only needs two steps: start squashed wide and short with a slight rotation, then spring back to normal scale and a slightly smaller rotation. Because 0% starts already deformed, the animation reads as a single decisive squish-and-recover rather than a slow build-up.
.btn:hover span,
.btn:focus-visible span {
background: var(--btn-color-hover);
color: #1a1026;
font-size: 1.2em;
box-shadow: 4px 4px 0 #1a1026, 0 0 0 3px #fff8f2 inset;
animation: squish 0.1s 1 ease-in forwards;
}
@keyframes squish {
0% { transform: scale(1.2, 0.58) rotate(-5deg); }
100% { transform: scale(1, 1) rotate(-4deg); }
}The two scale values are deliberately uneven: 1.2 on the x-axis and 0.58 on the y-axis. Scaling both axes by the same amount would just make the button bigger; scaling them in opposite directions — wider while shorter — is what squash-and-stretch actually is, borrowed straight from traditional animation. Ending the second keyframe at rotate(-4deg) instead of 0deg is a small deliberate detail too: settling one degree short of level keeps a hint of the tilt alive instead of the button snapping perfectly upright, which would read as mechanical rather than springy.
Step 5 — The Same Effect, Reachable by Keyboard
The original span had pointer-events: none, which is fine for click-through but says nothing about keyboard use. Chaining the hover selector with :focus-visible means tabbing to a button triggers the identical squish and outline that hovering does, and a visible focus ring on the .btn element itself — not just the inner span — makes sure the focus indicator is never accidentally clipped by the span's own border-radius.
.btn:focus-visible {
outline: 3px solid #fff8f2;
outline-offset: 4px;
border-radius: 20px;
}
@media (prefers-reduced-motion: reduce) {
.btn { animation: none; opacity: 1; }
.btn:hover span,
.btn:focus-visible span { animation: none; }
}Wrapping the reduced-motion rule around both the entrance and the squish is not optional set-dressing — without it, every button would stay invisible at opacity: 0 for anyone whose system has motion reduction turned on, since that opacity only ever gets reset to 1 by the animation the media query just disabled. Reduced motion means fewer transforms, not a blank page, so the opacity has to be reset by hand.
Step 6 — One Rule, Four Colors
Rather than writing a near-duplicate .btn span rule for each fruit, every button reads its resting and hover color from two custom properties, and only those two properties change per :nth-child. The shadow, radius, padding, and animation timing stay written exactly once.
.btn span {
--btn-color: #ff6b4a;
--btn-color-hover: #ffb830;
}
.btns li:nth-child(1) .btn span { --btn-color: #ff6b4a; --btn-color-hover: #ffb830; }
.btns li:nth-child(2) .btn span { --btn-color: #4ac6ff; --btn-color-hover: #7ee787; }
.btns li:nth-child(3) .btn span { --btn-color: #b06bff; --btn-color-hover: #ff8ad1; }
.btns li:nth-child(4) .btn span { --btn-color: #2fd67a; --btn-color-hover: #ffe14a; }Four buttons, one rule for shape and motion, two custom properties for color — retheming the whole list is a four-line diff, not a rewrite.
Tuning Reference
| Property | Example | Effect |
|---|---|---|
| animation-delay (per nth-child) | 0s / 0.1s / 0.2s / 0.3s | How staggered the list drops in; larger gaps read as a slower cascade |
| box-shadow offset | 4px 4px 0 | Thickness and direction of the drawn outline; 0 blur is required to keep it crisp |
| squish scale | scale(1.2, 0.58) | How wide/flat the squash reads; closer to (1,1) is a subtler pop |
| squish duration | 0.1s | Longer values feel like a slow-motion bounce instead of a snap |
| entrance easing | cubic-bezier(0.68, -0.55, 0.27, 1.55) | Values below 0 or above 1 are what produce the overshoot bounce |
| prefers-reduced-motion | animation: none; opacity: 1 | Disables both loops and restores visibility for users who need it |
Full Source Code
The complete component is a single self-contained HTML document — one list, one shared shadow rule, and two keyframe animations. No build step, no external assets, no JavaScript at all.
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/pop-buttons.html