CSS Elastic Bounce
A ball that bounces with a spring-like overshoot using a custom cubic-bezier easing.
A CSS elastic bounce comes from a cubic-bezier curve whose control points sit outside the range the animation is supposed to cover. cubic-bezier(0.68, -0.55, 0.265, 1.55) dips below the starting value and overshoots the ending one, so an element pulls back before it moves and springs past its target before settling. There is no spring physics in CSS, only a curve shaped to look like one.
Three balls run below on the same 140px arc: a squash and stretch bounce built from keyframes, a spring overshoot built from one easing curve, and a linear ball for comparison. Two more builds follow: a side by side demonstration of why a timing function applied to a multi stop animation behaves differently than most people expect, and the same easing used on a transition for a button press.
Three easings side by side
bounce
overshoot
(for reference)
HTML
<div class="bounce-stage">
<div class="bounce-lane">
<div class="ball ball-elastic"></div>
<span class="bounce-label">Elastic<br>bounce</span>
</div>
<div class="bounce-lane">
<div class="ball ball-spring"></div>
<span class="bounce-label">Spring<br>overshoot</span>
</div>
<div class="bounce-lane">
<div class="ball ball-linear"></div>
<span class="bounce-label">Linear<br>(for reference)</span>
</div>
<div class="bounce-floor"></div>
</div>
CSS
.bounce-stage {
position: relative;
display: flex;
gap: 3rem;
align-items: flex-end;
justify-content: center;
height: 200px;
padding-bottom: .5rem;
}
.bounce-lane {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
gap: .5rem;
height: 100%;
}
.ball {
width: 40px;
height: 40px;
border-radius: 50%;
}
/* Squash-and-stretch bounce */
.ball-elastic {
background: radial-gradient(circle at 35% 35%, #b8ff57, #6ddf00);
animation: bounce-elastic 1.4s cubic-bezier(0.36, 0.07, 0.19, 0.97) infinite;
}
/* Classic elastic overshoot */
.ball-spring {
width: 36px;
height: 36px;
background: radial-gradient(circle at 35% 35%, #38bdf8, #c084fc);
animation: bounce-spring 1.2s
cubic-bezier(0.68, -0.55, 0.265, 1.55)
infinite alternate;
}
.ball-linear {
width: 36px;
height: 36px;
background: radial-gradient(circle at 35% 35%, #ff6b6b, #ff9040);
animation: bounce-linear 1.4s linear infinite;
}
.bounce-label {
font-size: .72rem;
font-weight: 500;
color: #88888f;
text-align: center;
}
.bounce-floor {
position: absolute;
bottom: 1.75rem;
left: 10%;
right: 10%;
height: 2px;
background: #2a2a2d;
border-radius: 1px;
}
@keyframes bounce-elastic {
0%, 100% { transform: translateY(0) scaleX(1) scaleY(1); }
10% { transform: translateY(-140px); }
/* squash on impact */
30% { transform: translateY(0) scaleX(1.3) scaleY(0.7); }
40% { transform: translateY(-70px); }
60% { transform: translateY(0) scaleX(1.15) scaleY(0.85); }
70% { transform: translateY(-30px); }
85% { transform: translateY(0) scaleX(1.05) scaleY(0.95); }
}
@keyframes bounce-spring {
from { transform: translateY(0); }
to { transform: translateY(-140px); }
}
@keyframes bounce-linear {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-140px); }
}
Other ways to build it
Why an eased multi stop animation stalls
Both balls below fall the same 140px over the same 1.6 seconds through five keyframes. The left one spaces its stops evenly and applies ease-in-out, so the curve restarts at 25%, 50% and 75% and the ball decelerates to a near stop three times on the way down. The right one keeps the timing linear and moves the acceleration into the distances instead: a falling object has covered 6% of its drop a quarter of the way through, 25% at the halfway point and 56% at three quarters. Only the second one looks like gravity. This is the same correction the confetti example on this site needed after an eased version kept hesitating mid flight.
on every stop
on a squared curve
HTML
<div class="bb-stage">
<div class="bb-lane">
<div class="bb-ball bb-eased"></div>
<span class="bounce-label">ease-in-out<br>on every stop</span>
</div>
<div class="bb-lane">
<div class="bb-ball bb-parabolic"></div>
<span class="bounce-label">linear, distances<br>on a squared curve</span>
</div>
<div class="bb-floor"></div>
</div>
CSS
.bb-eased {
animation: bounce-drop-eased 1.6s ease-in-out infinite alternate;
}
.bb-parabolic {
animation: bounce-drop-parabolic 1.6s linear infinite alternate;
}
/* even distances, eased: the curve restarts at every stop, so the
ball slows to a crawl three times on the way down */
@keyframes bounce-drop-eased {
0% { transform: translateY(-140px); }
25% { transform: translateY(-105px); }
50% { transform: translateY(-70px); }
75% { transform: translateY(-35px); }
100% { transform: translateY(0); }
}
/* linear timing, distances on a t squared curve: 6%, 25%, 56% of the
drop at 25%, 50% and 75% of the time */
@keyframes bounce-drop-parabolic {
0% { transform: translateY(-140px); }
25% { transform: translateY(-131px); }
50% { transform: translateY(-105px); }
75% { transform: translateY(-61px); }
100% { transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.bb-eased,
.bb-parabolic { animation: none; }
}
The same curve on a transition, for a press
A transition has one segment, so an elastic curve runs exactly once from start to finish and the per segment problem cannot appear. Hover or tab to a chip below and it springs past its target size before settling; press it and :active compresses it with a plain ease-out, because a press should feel firm rather than springy. Keep the whole thing under half a second. The overshoot is feedback the reader asked for, and feedback that arrives late reads as lag no matter how well it is drawn.
HTML
<div class="eb-chips">
<button class="eb-chip">Hover me</button>
<button class="eb-chip eb-chip-sky">Press me</button>
<button class="eb-chip eb-chip-violet">Tab to me</button>
</div>
CSS
.eb-chip {
padding: .6rem 1.4rem;
border: 1px solid #2a2a2d;
border-radius: 999px;
background: #1c1c1e;
color: #b8ff57;
font-size: .9rem;
font-weight: 600;
cursor: pointer;
transition: transform .45s cubic-bezier(0.68, -0.55, 0.265, 1.55),
border-color .2s ease;
}
.eb-chip:hover,
.eb-chip:focus-visible {
transform: scale(1.14);
border-color: #b8ff57;
}
/* a press should land, not spring, so the active state drops the
elastic curve for a short ease-out */
.eb-chip:active {
transform: scale(0.94);
transition: transform .1s ease-out;
}
.eb-chip:focus-visible {
outline: 2px solid #b8ff57;
outline-offset: 3px;
}
@media (prefers-reduced-motion: reduce) {
.eb-chip { transition: border-color .2s ease; }
.eb-chip:hover,
.eb-chip:focus-visible,
.eb-chip:active { transform: none; }
}
How it works
cubic-bezier(0.68, -0.55, 0.265, 1.55) is the classic elastic easing. The negative second value (-0.55) makes the animation briefly go backwards before springing forward, and the fourth value above 1.0 (1.55) makes it overshoot the target before settling. The realistic bounce instead uses @keyframes with scaleX and scaleY squash and stretch at the floor, a trick borrowed from hand drawn animation.
The four numbers in cubic-bezier(x1, y1, x2, y2) are two control points for a curve that always runs from (0,0) to (1,1). The horizontal axis is progress through time and the vertical axis is progress through the value being animated. Both x values must stay between 0 and 1, because time cannot run backwards. The y values have no such limit, and that is where the elasticity comes from. A negative y1 pulls the first part of the curve below zero, so the element moves the wrong way before it moves the right way. A y2 above 1 pushes the curve past its destination, so the element sails through the target and comes back.
The single most misunderstood thing about animation-timing-function is that it applies to every segment between keyframes, not to the animation as a whole. An animation with stops at 0%, 25%, 50%, 75% and 100% and ease-in-out on it does not ease once over the whole run. It eases four separate times, decelerating to a near stop at every intermediate keyframe. On a bouncing ball that reads as a stall in mid air. The first variant below puts the two versions next to each other so the difference is visible rather than theoretical.
The fix is not a different easing, it is a different set of distances. Keep the timing linear and place the keyframe stops on the curve you want. A falling object covers ground in proportion to the square of the elapsed time, so stops at 25%, 50% and 75% of the duration should sit at 6%, 25% and 56% of the distance. That is exactly the shape used in the linear variant below, and it is the same fix applied to the confetti example on this site after an eased multi stop version kept stalling halfway down.
Squash and stretch is what separates a bounce from a translation. At the moment the ball meets the floor the demo sets scaleX(1.3) scaleY(0.7), which keeps the apparent area roughly constant while the shape deforms, and the ball recovers to scale(1) as it leaves. One detail is easy to miss: transform-origin defaults to the center of the element, so a ball that loses 30% of its height at impact also lifts its base off the floor by 15% of that height. Set transform-origin: 50% 100% to pin the base in place, which is what a heavy object should do.
The spring ball uses infinite alternate rather than a full cycle of keyframes. alternate plays the keyframes forward, then backward, and it reverses the timing function on the way back, so an ease-in going up becomes an ease-out coming down without a second declaration. That is usually what you want for a two state loop, and it halves the keyframe block. The same curve family shows up on the magnetic button, where a transition rather than an animation carries it, and on the CSS bar chart as cubic-bezier(0.16, 1, 0.3, 1).
CSS properties used
animation-timing-function- Applies to every segment between keyframes, not to the animation as a whole. This is the property most responsible for multi stop animations looking wrong.
cubic-bezier()- Two control points on a curve from (0,0) to (1,1). The x values are clamped between 0 and 1; the y values are not, and values outside that range produce anticipation and overshoot.
transformtranslateYfor the arc andscaleXwithscaleYfor the squash. Both are composited, which is why a bounce stays smooth on a busy page.transform-origin50% 100%keeps the base of a squashing element on the floor. The default center origin lifts it by half the height it loses.animation-directionalternateplays the keyframes forward then backward and reverses the timing function with them, which turns a one way set of keyframes into a loop.transition-timing-function- The same curves apply to transitions. A transition has only one segment, so the per segment behavior that trips up keyframes cannot bite here.
Browser support
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
animation | 4 | 5 | 5.1 | 12 |
transition | 4 | 5 | 5.1 | 12 |
transform | 4 | 3.5 | 3.1 | 12 |
prefers-reduced-motion | 74 | 63 | 10.1 | 79 |
Figures come from Can I Use and cover cubic-bezier(), which has been part of the transitions and animations syntax from the start. The newer linear() easing function, which lets you approximate a real spring with a list of points, has no Can I Use entry yet and so gets no row here. It degrades to the default easing when unsupported, which makes it safe to try but not safe to depend on.
Accessibility notes
Overshoot and bounce are exactly the kind of motion the reduced motion setting exists for. An element that springs past its target and comes back moves further and changes direction more often than a plain fade, and both of those make it more provoking for someone with a vestibular condition. Under prefers-reduced-motion: reduce, drop to a short opacity change or remove the movement completely rather than slowing the bounce down.
A looping bounce is decoration and should be marked aria-hidden="true" unless it carries status. If it does carry status, such as a load indicator, the status has to exist as text or as an ARIA live region as well, because the movement itself is invisible to a screen reader and to anyone who has motion turned off.
When an elastic curve is attached to a button press, keep the total duration under about 400ms. An overshoot that takes longer than that delays the feedback the reader is waiting for, and for someone using a keyboard the delay lands between pressing the key and seeing anything happen. The chip variant below settles in 450ms, which is at the outer edge of comfortable.
What you can build with it
- Button and chip presses. A short overshoot on
:hoverand a compression on:activegives a control a physical feel without a shadow trick. - Modal and toast entrances. A panel that arrives slightly too large and settles reads as arriving rather than appearing, which helps the reader locate it.
- Loading indicators. A bouncing dot row, each dot on the same keyframes with a staggered delay, which is lighter than a spinner and less monotonous.
- Drag and drop feedback. An item dropping into a slot with a small squash on landing tells the reader the drop registered.
- Empty state illustrations. A slow squash and stretch loop that gives an otherwise blank panel something to look at without implying progress.
Mistakes worth avoiding
- Assuming the timing function spans the whole animation. It runs again on every keyframe segment, so an eased five stop animation decelerates four times on the way through and looks like it is snagging.
- Putting an x value outside 0 to 1 in
cubic-bezier(). The declaration is invalid and the browser falls back to the default easing, which usually looks close enough that the mistake goes unnoticed for a while. - Squashing about the center. The default
transform-originis the middle of the element, so a ball that flattens at the floor also floats a few pixels above it. Move the origin to50% 100%. - Overshooting a value that cannot go past its target. An elastic curve on
opacitypushes it above 1 and below 0, and both are clamped, so the animation holds still at each end instead of springing. Use elastic curves ontransformand leaveopacityon a simple ease. - Running an elastic curve over a long duration. Past about half a second the overshoot stops reading as energy and starts reading as a bug, especially on an interaction the reader triggered themselves.
Frequently asked questions
What does cubic-bezier(0.68, -0.55, 0.265, 1.55) actually do?
Why does my keyframe animation pause between steps?
animation-timing-function: linear and space the keyframe values themselves along the curve you want.How do I make a bounce look like real gravity?
Can I use an elastic easing on a transition?
Why does my ball float above the floor when it squashes?
transform-origin defaults to the center of the element, so scaling the height down shrinks it towards the middle and lifts the bottom edge. Set transform-origin: 50% 100% and the base stays where it is.