Aduok Code

Squishy Pop Buttons, Built from One Hard Shadow, One Keyframe, and Staggered Delays

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.

What you will learn

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.

01
Part One
A List, Not Four One-Off Buttons

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.

HTML
<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>
02
Part Two
A Hard box-shadow as a Cartoon Outline

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.

CSS
.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.

03
Part Three
Staggering the Entrance with nth-child

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.

CSS
.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.

04
Part Four
Faking Squash-and-Stretch with One Keyframe

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.

CSS
.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.

05
Part Five
Focus States and Reduced Motion

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.

CSS
.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; }
}
A gotcha worth knowing about

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.

06
Part Six
Swapping the Palette per Button

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.

CSS
.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

PropertyExampleEffect
animation-delay (per nth-child)0s / 0.1s / 0.2s / 0.3sHow staggered the list drops in; larger gaps read as a slower cascade
box-shadow offset4px 4px 0Thickness and direction of the drawn outline; 0 blur is required to keep it crisp
squish scalescale(1.2, 0.58)How wide/flat the squash reads; closer to (1,1) is a subtler pop
squish duration0.1sLonger values feel like a slow-motion bounce instead of a snap
entrance easingcubic-bezier(0.68, -0.55, 0.27, 1.55)Values below 0 or above 1 are what produce the overshoot bounce
prefers-reduced-motionanimation: none; opacity: 1Disables 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.

HTML — pop-buttons.html (complete)
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/pop-buttons.html
Want to see it in action?Watch the full build on YouTube