A button is the one element on your page people actually poke. So it should poke back. Not with a light show, just a small, fast acknowledgement that the click landed, the way a real switch has a bit of give before it seats.
Most button animation css lives in three moments: the cursor arriving, the click committing, and the wait after. Hover, press, load. Get those three honest and you are basically done. This guide walks each one with code you can paste, plus the accessibility bits that keep a nice button from turning into a nag. It sits under the broader guide to CSS animation, so if a property here reads like a stranger, that page has the mechanics.
One thing up front. The giant bouncy hover scale is overrated. A button that inflates to 115% and springs back looks like a toy after the third click. Keep the motion small and quick, and it reads as quality instead of noise.
Button animation css starts with :hover and :focus-visible
Hover is a transition, not an animation. There is no @keyframes here and there does not need to be. You define a base state, you define a :hover state, and transition smooths the trip between them. That distinction matters enough that it has its own page: CSS transition vs animation. Buttons are the clearest place the difference shows up.
The move most hover effects want is a tiny lift. Scale, not size. Animating transform: scale() stays on the compositor and never touches layout, so it runs smooth even on a tired laptop. Bumping width or padding would reflow the whole row. Do not do that.
.btn {
cursor: pointer; /* signals it is clickable */
border: 0;
padding: 0.7em 1.4em;
border-radius: 10px;
background: #4f46e5;
color: #fff;
font: inherit;
/* transition names the properties, not "all" */
transition: transform 150ms ease-out, background-color 150ms ease-out;
}
.btn:hover {
transform: scale(1.03); /* small on purpose */
background: #4338ca;
}
.btn:focus-visible {
outline: 2px solid #4f46e5;
outline-offset: 3px;
}
Two details earn their keep. First, cursor: pointer, because the hand cursor is half the affordance and people notice when it is missing. Second, :focus-visible instead of plain :focus. Plain focus fires on mouse clicks too, which is why so many sites nuke the outline and quietly lock out keyboard users. :focus-visible shows the ring only when the browser thinks a keyboard drove the focus, so mouse users get a clean press and keyboard users get their outline. It has been Baseline since March 2022 (Safari picked it up in 15.4), so you can lean on it without a fallback in any current project.
Notice the transition lists its properties by name. Reaching for transition: all feels convenient and then animates something you never meant to, like a border color inherited from a theme change. Name what moves.
Press feedback with :active
Push the button down. That is the whole idea. When a real button depresses, it gets slightly smaller and closer to the surface, and :active is how you fake that in a browser. It applies while the mouse is held or the finger is touching, then releases the instant they let go.
.btn:active {
transform: scale(0.97); /* a hair smaller than resting */
transition-duration: 60ms; /* down should feel faster than up */
}
The trick nobody tells you: shorten the duration on the way down. A press should feel instant, almost mechanical, while the release can breathe. Sixty milliseconds on :active against a hundred and fifty on hover gives you that snap. It is a five-character change and it is the difference between a button that feels dead and one that feels clicky.
On touch screens :active can lag or stick because the browser waits to see if you are scrolling. That is not your CSS being wrong, it is the platform being cautious. Do not try to out-clever it with JavaScript for a simple press. The small delay is acceptable, and the alternative is a pile of touch handlers you will regret.
The ripple with a pseudo-element
Do you actually need a ripple? Honest answer, usually no. The Material-style ink ripple is the most copied button effect on the web and the most overdone. But it is a genuinely nice bit of feedback for primary actions, and you can build a decent one in pure CSS if you accept one limit, which I will get to.
The pattern: a ::before pseudo-element holds the ripple layer, a small circle sitting dead center. On :active it runs a keyframe that scales the circle up while fading it out. The button clips it with overflow: hidden so the ink stays inside the shape.
.btn {
position: relative;
overflow: hidden; /* clips the ripple to the button */
}
.btn::before {
content: "";
position: absolute;
left: 50%;
top: 50%;
width: 6px;
height: 6px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.55);
transform: translate(-50%, -50%) scale(0);
opacity: 0;
pointer-events: none; /* never eats the click */
}
.btn:active::before {
animation: ripple 550ms ease-out;
}
@keyframes ripple {
from { transform: translate(-50%, -50%) scale(1); opacity: 0.55; }
to { transform: translate(-50%, -50%) scale(60); opacity: 0; }
}
Here is the limit. A pure-CSS ripple always starts from the center, because CSS has no idea where you clicked. The real Material ripple grows from the exact point your finger touched, and that needs a line or two of JavaScript to read the pointer coordinates and set them as custom properties. If center-out is good enough, the code above ships with zero script. If you need click-point origin, that is the moment to reach for JS, and it is a small reach.
Keep pointer-events: none on the pseudo-element. Without it, the growing circle can intercept the very click that spawned it, and you will spend an afternoon wondering why the second click in a double-click gets swallowed.
A loading spinner button
A spinner is the only animation on this page with a real job. Hover and press are polish. The loading state is information: it tells someone the form submitted, the request is in flight, and mashing the button again will not help. So this one is worth getting right.
The cleanest hook is aria-busy, because it does double duty. You toggle it in JavaScript when the request starts, it styles the spinner, and it tells assistive tech the control is working. One attribute, two payoffs.
.btn[aria-busy="true"] {
color: transparent; /* hide the label, keep the width */
pointer-events: none;
}
.btn[aria-busy="true"]::after {
content: "";
position: absolute;
inset: 0;
margin: auto; /* dead-centers the circle */
width: 1.1em;
height: 1.1em;
border: 2px solid rgba(255, 255, 255, 0.4);
border-top-color: #fff; /* the one lit segment */
border-radius: 50%;
animation: btn-spin 700ms linear infinite;
}
@keyframes btn-spin {
to { transform: rotate(360deg); }
}
The spin has to be linear and infinite. A spinner on ease-in-out speeds up and slows down every lap, which looks like it is struggling, so keep the speed constant. And color: transparent hides the text while holding the button’s width, so the layout does not jump when the label vanishes. In JavaScript you set btn.setAttribute('aria-busy', 'true') before the fetch and remove it when the promise settles. That is the whole wiring.
One gotcha with the trick above: because the label is transparent, you cannot borrow currentColor for the spinner or it disappears too. That is why the border colors are hard-coded here.
Keeping it accessible
Leave the focus ring alone. If you take one rule from this page, take that one. A visible focus indicator is not decoration, it is how keyboard and switch users know where they are, and :focus-visible already solved the “but it shows on mouse clicks” complaint that made people delete it in the first place.
The bigger obligation is motion preference. Some people get genuinely nauseated by movement, and the browser lets them say so through prefers-reduced-motion. Respect it. For a button that mostly means dropping the hover scale and the ripple, which are pure flourish, while keeping a signal that still communicates.
@media (prefers-reduced-motion: reduce) {
.btn {
transition: none;
}
.btn:hover,
.btn:active {
transform: none; /* kill the scale, keep the color change */
}
.btn:active::before {
animation: none; /* no ripple */
}
.btn[aria-busy="true"]::after {
animation-duration: 1.4s; /* slow the spin, do not remove it */
}
}
See the split. Hover scale and ripple get switched off, because they are optional delight. The spinner gets slowed, not killed, because a frozen spinner reads as broken and a working button needs to look like it is working. That is the judgement call reduced-motion always comes down to: decorative movement goes, meaningful movement stays but calms down. The full version of that reasoning lives in the guide to accessible animation with prefers-reduced-motion.
Last thing, and it is boring but true. Use a real <button> element. A styled <div> with a click handler gives you none of this for free: no focus, no :active, no keyboard activation, no announcement to screen readers. All the animation in the world does not fix a control the keyboard cannot reach. Start with the right element, then make it move.
If you want the arrow-driven cousin of these effects, the little “next step” and scroll cues, that is a whole technique of its own over in arrow animation css. Same instincts, different shape.