9 CSS Button Animations (SCSS + React, copy-paste)
A CSS button animation is a small motion that tells the user a button noticed them: hover, press, wait, fail, or confirm. Here are nine, one for each moment, all built with transform and opacity only.
Click to trigger · all in one zip
- 01 Ripple
- 02 Lift
- 03 Underline slide
- 04 Magnetic
- 05 Rotating border
- 06 Loading state
- 07 Arrow push
- 08 Shake
- 09 Hold to confirm
The order isn't "most popular first." It's the order a button lives through: it has to get noticed (01 through 05), respond to a press (06 and 07), admit a failure (08), and ask twice before doing something dangerous (09). Try them in the grid above; each section shows only the core lines, and the complete files are in the zip.
01Ripple
A circular wave spreads out from the click point and fades. Use it on pay and confirm buttons, where the press has to feel like it landed.
.btn {
position: relative; overflow: hidden; isolation: isolate;
&::after {
content: ""; position: absolute;
left: var(--x, 50%); top: var(--y, 50%);
width: var(--d, 0); height: var(--d, 0);
border-radius: 50%; background: $ripple;
transform: translate(-50%, -50%) scale(0); opacity: 0;
}
&.is-rippling::after { animation: ripple $duration $easing forwards; }
}
@keyframes ripple { to { transform: translate(-50%, -50%) scale(1); opacity: 0; } }
02Lift
The button rises 6px on hover, and its shadow grows. It suits card-style buttons and add-to-cart buttons, anywhere the button should feel like something you can pick up.
$lift: 6px; $duration: 220ms; $shadow: rgba(23, 20, 26, .32);
.btn {
will-change: transform;
transition: transform $duration cubic-bezier(.2, .8, .2, 1), box-shadow $duration;
&:hover, &:focus-visible { transform: translateY(-$lift); box-shadow: 0 14px 24px $shadow; }
&:active { transform: translateY(-2px); box-shadow: 0 6px 12px $shadow; }
}
03Underline slide
The underline draws in from the left and, when the cursor leaves, slides out to the right. Use it for text links and navigation.
.link::after {
content: ""; position: absolute; left: 0; right: 0; bottom: 0;
height: $thickness; background: currentColor;
transform: scaleX(0); transform-origin: right;
transition: transform $duration cubic-bezier(.2, .8, .2, 1);
}
.link:hover::after { transform: scaleX(1); transform-origin: left; }
04Magnetic
When the cursor comes within a set radius, the button drifts toward it. Use it on a landing page's single call to action; two of these on one screen turn the page into a toy.
document.addEventListener('pointermove', function (e) {
var r = btn.getBoundingClientRect();
var dx = e.clientX - (r.left + r.width / 2), dy = e.clientY - (r.top + r.height / 2);
if (Math.hypot(dx, dy) < radius) btn.style.transform = 'translate(' + dx * strength + 'px,' + dy * strength + 'px)';
else btn.style.transform = '';
});
05Rotating border
A gradient border spins continuously around the button. Use it on the one button that has to look special, such as an AI or premium feature.
.btn {
position: relative; overflow: hidden; isolation: isolate;
padding: $border; border-radius: 16px;
&::before {
content: ""; position: absolute; inset: -60%;
background: conic-gradient(from 0deg, $colors);
animation: spin $duration linear infinite; z-index: -1;
}
}
.btn__in { display: block; border-radius: 13px; background: #17141a; }
@keyframes spin { to { transform: rotate(360deg); } }
06Loading state
On press, the label fades out and a spinner fades in. Use it for save and submit buttons, and keep the width fixed so the elements next to it don't shift.
.btn { display: grid; place-items: center; min-width: 200px; }
.btn__label, .btn__spin { grid-area: 1 / 1; transition: opacity $duration, transform $duration; }
.btn__spin {
width: 22px; height: 22px; border-radius: 50%;
border: 3px solid rgba(255, 255, 255, .35); border-top-color: #fff;
opacity: 0; transform: scale(.6); animation: spin $spin linear infinite;
}
.btn.is-loading .btn__label { opacity: 0; transform: translateY(6px); }
.btn.is-loading .btn__spin { opacity: 1; transform: scale(1); }
07Arrow push
On hover, the arrow line stretches and the head pushes to the right. Use it for next, more, and continue; the arrow is just one line and one rotated corner, so no icon font is needed.
.arrow__line { transform-origin: left; transform: scaleX(.55); transition: transform $duration; }
.arrow__head {
width: 12px; height: 12px;
border-top: 2.5px solid currentColor; border-right: 2.5px solid currentColor;
transform: rotate(45deg); transition: transform $duration;
}
.btn:hover .arrow__line { transform: scaleX(1); }
.btn:hover .arrow__head { transform: rotate(45deg) translate($push * .7, -$push * .7); }
08Shake
On error, the button shakes side to side three times and stops. Use it for a failed login or invalid input; anything more than five shakes reads as angry.
.btn.is-shaking { animation: shake $duration cubic-bezier(.36, .07, .19, .97) both; }
@keyframes shake {
10%, 90% { transform: translateX(-$amount * .5); }
20%, 80% { transform: translateX($amount); }
30%, 70% { transform: translateX(-$amount); }
40%, 60% { transform: translateX($amount * .7); }
50% { transform: translateX(-$amount * .7); }
}
09Hold to confirm
The background fills while you hold the button, and the action fires once it's full; release early and it resets. Use it for delete or unsubscribe instead of a confirmation modal. JS adds is-holding on pointerdown and removes it on pointerup, and the fill animation's animationend event is the signal to fire.
.btn__fill {
position: absolute; inset: 0; background: $fill;
transform: scaleX(0); transform-origin: left; z-index: -1;
transition: transform 200ms ease-out;
}
.btn.is-holding .btn__fill { animation: fill $hold linear forwards; }
@keyframes fill { from { transform: scaleX(0); } to { transform: scaleX(1); } }
Where it breaks: three traps
All nine animate only transform and opacity, and that's deliberate. Animating width or top forces the browser to recompute layout every frame, while transform and opacity are handled entirely in the compositing step. MDN's animation performance guide recommends those two properties for exactly this reason. The tempting mistake is in 06: shrinking the button while it loads breaks the rule and shoves the elements next to it around. Keep the width fixed and change only what's inside.
The second trap is size. The first time I built 01, I set a 320px ripple on a small button, and the circle covered the whole button in two frames, so it read as a quick flash instead of a wave. Compute the ripple diameter as twice the button's longer side, and don't go below 600ms. Those values are already baked into the nine files, and the archive password is xu8ewsq8 if you want them now.
The third trap is touch. 02, 03, and 07 depend on hover, which doesn't exist on phones. The files in the zip attach the same motion to :focus-visible, and on touch devices the :active state stands in for hover.
Accessibility
All nine handle prefers-reduced-motion: reduce. The rule is to remove the motion but keep the feedback: 02 keeps the shadow without lifting, 06 shows a static ring, and 08 changes color instead of shaking. 09 is the exception: holding to confirm is a safety mechanism rather than decoration, so the fill stays. Browser support for the media query is listed on MDN prefers-reduced-motion.
@media (prefers-reduced-motion: reduce) {
.btn.is-demo, .btn.is-shaking { animation: none; }
.btn:hover { transform: none; }
}
More button and loader collections live in the CSS animations category, and you can read what this site is about on the about page.
FAQ
Can I use all nine in one project?
You can, but keep it to two kinds per screen. 04 (magnetic) and 05 (rotating border) are "one special button" effects, so use each at most once per page. The rest are tied to specific situations, so they rarely collide.
What replaces class toggling in React?
The components in the zip's react/ folder take state as props: loading for 06, a shakeKey failure counter for 08, and an onConfirm callback for 09. The SCSS modules only read var() values, so you pass the three variables in as props.
The animation looks janky. What do I check first?
First, make sure nothing other than transform and opacity is animating. Then add will-change: transform to the moving element only; putting it on everything makes things slower, not faster.
Comments
Comments open soon. Use the contact page meanwhile.