9 CSS Button Click Feedback Effects, Copy-Paste
CSS button click feedback is the flash, scale, or color shift a button shows the instant it's pressed, confirming a tap landed — nine effects below, one
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Ring pulse (outline, not fill)
- 02 Press shrink + shadow flatten
- 03 Touch-only :active via pointer: coarse
- 04 Long-press delay reveal (no fill bar)
- 05 List row tap highlight
- 06 Card tap scale + brightness dim
- 07 Tap-confirm jitter (not the error shake)
- 08 Checkbox tap feedback
- 09 Inline link tap feedback
These nine sit in the order one tap actually moves through a screen: 01 and 02 confirm a plain button press, 03 and 04 change how or when that confirmation fires, 05 and 06 scale the same idea up to a whole row or card, and 07 through 09 close on an icon jitter, a checkbox, and an inline link. The grid above replays each state on a loop by itself — every panel sits in its own iframe with nothing wired up to click, so watching is the only option here. Four of the nine, 02, 03, 04, and 06, reach for touch-specific media queries or touch-action instead of treating every tap as a mouse click wearing a costume.
01Ring pulse (outline, not fill)
A box-shadow ring blooms outward from the button's own edge and fades, triggered by a pointerdown listener that swaps in an .is-ringing class rather than leaning on :active alone. So the ring finishes its full animation even after the finger or pointer lifts. Reach for it on icon buttons that already use a filled ripple elsewhere, where a ring supplies a second, visibly different signal instead of stacking two near-identical waves.
.btn::after {
content: "";
position: absolute;
inset: -2px;
border-radius: inherit;
box-shadow: 0 0 0 0 rgba($color, .55);
opacity: 0;
pointer-events: none;
}
.btn.is-ringing::after {
animation: ring $duration $easing forwards;
}
@keyframes ring {
0% { box-shadow: 0 0 0 0 rgba($color, .55); opacity: 1; }
100% { box-shadow: 0 0 0 26px rgba($color, 0); opacity: 0; }
}
02Press shrink + shadow flatten
While pressed, the button shrinks to scale(.96) and its box-shadow flattens at the same time, then springs back the instant the pointer lifts with no spring bounce. touch-action: manipulation strips the tap delay and double-tap zoom that would otherwise blur that timing on a phone, which is why submit and checkout buttons — anywhere confirming the press matters most — lean on it.
.btn {
transform: scale(1);
transition: transform $duration $easing, box-shadow $duration $easing;
touch-action: manipulation;
}
.btn:active {
transform: scale(.96);
box-shadow: $shadow-press;
}
03Touch-only :active via pointer: coarse
@media (hover: none) and (pointer: coarse) scopes the :active background to touch devices only, so a mouse-driven browser never even parses that rule and can't leave a stuck hover-gray color behind after a click. It fits mobile web buttons that otherwise leave a gray background lingering after every tap.
@media (hover: none) and (pointer: coarse) {
.btn:active {
background: $stage-ink;
color: $subject-cream;
}
}
04Long-press delay reveal (no fill bar)
Nothing happens the instant a finger lands — a transition-delay of 600ms holds the response back, and only once that threshold passes does the card grow while its border and shadow darken. touch-action: none keeps the browser's own scroll gesture from competing with the hold, which suits a card previewing a context menu, where a short accidental tap should trigger nothing at all.
.card {
transition: transform $duration $easing $hold,
box-shadow $duration $easing $hold,
border-color $duration $easing $hold;
touch-action: none;
}
.card:active {
transform: scale(1.06);
box-shadow: $shadow-raised;
border-color: $color;
}
05List row tap highlight
With -webkit-tap-highlight-color turned off, tapping a row flashes a custom background color that fades back out, instead of leaving the browser's own gray tap rectangle behind. Each row also holds a 44px minimum height so the tappable strip matches a finger's width rather than the text inside it. That matters most on settings menus and chat lists where rows sit close together.
.list li {
min-height: 44px;
-webkit-tap-highlight-color: transparent;
transition: background $duration $easing;
}
.list li:active {
background: rgba($color, .18);
transition: background 0s;
}
06Card tap scale + brightness dim
scale(.98) and filter: brightness(.94) apply together the moment a card is tapped, so the whole surface reads as pressed rather than just its edges. touch-action: manipulation blocks the double-tap zoom a quick second tap would otherwise trigger, which fits product or thumbnail cards where the tappable surface is large.
.card {
transform: scale(1);
filter: brightness(1);
transition: transform $duration $easing, filter $duration $easing;
touch-action: manipulation;
}
.card:active {
transform: scale(.98);
filter: brightness(.94);
}
07Tap-confirm jitter (not the error shake)
Right after a tap, the icon jitters twice within about 140ms at a ±2px amplitude while its color fills from a pale tint to solid, mimicking a phone's haptic buzz on screen. That's a far smaller motion, and a different purpose, than a three-swing error shake — reach for it on like or favorite buttons that need a brief success confirmation rather than a warning.
.btn:active { animation: jitter $duration $easing; }
.btn:active .btn__icon { color: $error; }
@keyframes jitter {
0% { transform: scale(1) translate(0, 0); }
25% { transform: scale(1.18) translate(-2px, 1px); }
50% { transform: scale(1.1) translate(2px, -1px); }
75% { transform: scale(1.16) translate(-1px, 1px); }
100% { transform: scale(1) translate(0, 0); }
}
08Checkbox tap feedback
Tapping a custom checkbox draws its check mark through stroke-dashoffset while the box itself pops to scale(1.08) once, so the state change gets confirmed twice in the same moment. The real input stays visually hidden but keeps its own focus ring, and the label wrapping both spans holds a 44px tap target even though the visible box itself measures only 26px.
.cb__input:checked ~ .cb__box {
border-color: $color;
background: $color;
transform: scale(1.08);
}
.cb__input:checked ~ .cb__box .cb__check {
stroke-dashoffset: 0;
}
.cb__check {
stroke-dasharray: 24;
stroke-dashoffset: 24;
transition: stroke-dashoffset $duration $easing;
}
09Inline link tap feedback
Tapping a link inside a paragraph flashes a short color block behind the letters and lets it fade back out, leaving a press signal in place even where the browser's own tap-highlight has been switched off. Padding paired with a matching negative margin keeps the flash aligned to the text instead of spilling past the link's own box. That suits blog posts and terms pages where links sit inline inside running sentences.
.p__link {
padding: 2px 3px;
margin: -2px -3px;
background: transparent;
transition: background $duration $easing;
}
.p__link:active {
background: rgba($color, .32);
transition: background 0s;
}
Where it breaks — the timing trap
Item 09 is the one demo on this page that almost failed the render check every collection here has to pass before it goes live. Its flash is a single linear background-color fade, and a linear curve spreads a color change into equal steps across every frame rather than clustering it anywhere — unlike an eased curve, whose steps bunch up hardest right where two frames sit closest together. This site's render pipeline marks a frame as unmoved once its per-pixel color delta falls under a fixed threshold, and spreading 09's fade evenly across 24 frames left several of them sitting right on that line, nearly reading as a static block of text with a link glued to it. The version that finally passed measures an average intensity of 16.2 against a floor of 15, and 8 out of 24 frames counted as moved against a floor of 6 — a real margin now, but one timing tweak away from slipping back under. MDN's animation-timing-function reference covers exactly this gap between linear and eased pacing. The narrowed timing that fixed it ships in the archive, unlocked with e8dzq4mp typed exactly as it reads on this line, no spaces added anywhere.
Accessibility
All nine drop their motion under prefers-reduced-motion: reduce, but seven of them keep the confirmation itself rather than just switching a transition off. Everything except 01 and 07 sets transition: none, so whatever end state a tap already produces — a smaller scale, a darker card, a highlighted row, a drawn checkmark, a flashed link — appears instantly instead of easing in. 07 removes its jitter animation outright with .btn:active { animation: none; }, yet the confirming icon-color rule sits in a separate line outside that animation, so tapping the heart still fills it in even with motion off. 01 is the outlier: the ring is removed entirely at opacity: 0, replaced by nothing more than the small brightness dip its plain :active state already carries.
Two of the nine also carry a :focus-visible outline so keyboard users get the same signal a pointer would:
.card:focus-visible {
outline: 3px solid $color;
outline-offset: 3px;
}
Tap targets are sized in the same CSS rather than left to chance — 05's list rows and 08's checkbox label both carry min-height: 44px, matching the floor WCAG 2.5.8 Target Size (Minimum) sets for anything a finger needs to land on, even though 08's visible checkbox square itself measures only 26px. See the rest of this line of demos on the CSS category hub, or read what this site does and doesn't sell on the about page.
FAQ
Does the ring in 01 need :active, or does pointerdown avoid an iOS delay?
It avoids the delay on purpose. Safari on iOS only applies :active styles right away once some element in the page carries a touchstart listener — without one, a tap-and-release can finish before the style ever paints, a quirk MDN's :active reference notes directly. 01 sidesteps the whole issue by never depending on :active for its ring in the first place; a pointerdown listener swaps the class in immediately on every platform.
Does the touch-only style in 03 still apply on a touchscreen laptop that also has a mouse?
(hover: none) and (pointer: coarse) is judged against the device's primary pointer, not every input it happens to own. A touchscreen laptop that reports touch as primary still gets 03's background; the moment its mouse becomes primary, that block goes quiet on its own. Caniuse's interaction media features table shows Chrome, Safari, and Firefox have all supported this pairing for years.
Can all nine css button click feedback effects sit on one page together?
Yes — each one is scoped to its own class (.btn, .card, .list, .cb, .p__link), so nothing here shares state across effects. The one thing to avoid is stacking two of them on the very same element, such as 02's instant press together with 04's 600ms delay; a button can't cleanly tell a visitor which timing to expect, so pick one feedback style per element instead.