CSS Elastic Bounce

A ball that bounces with a spring-like overshoot using a custom cubic-bezier easing.

Published July 6, 2026 Intermediate 8 min read

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

Elastic
bounce
Spring
overshoot
Linear
(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.

ease-in-out
on every stop
linear, distances
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.
transform
translateY for the arc and scaleX with scaleY for the squash. Both are composited, which is why a bounce stays smooth on a busy page.
transform-origin
50% 100% keeps the base of a squashing element on the floor. The default center origin lifts it by half the height it loses.
animation-direction
alternate plays 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

FeatureChromeFirefoxSafariEdge
animation455.112
transition455.112
transform43.53.112
prefers-reduced-motion746310.179

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 :hover and a compression on :active gives 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-origin is the middle of the element, so a ball that flattens at the floor also floats a few pixels above it. Move the origin to 50% 100%.
  • Overshooting a value that cannot go past its target. An elastic curve on opacity pushes it above 1 and below 0, and both are clamped, so the animation holds still at each end instead of springing. Use elastic curves on transform and leave opacity on 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?
It defines a curve from (0,0) to (1,1) with control points at (0.68, -0.55) and (0.265, 1.55). The negative y on the first point drags the beginning of the animation below its start value, so the element pulls back. The y above 1 on the second point pushes the end past the target, so it overshoots and returns.
Why does my keyframe animation pause between steps?
The timing function is applied to each segment between keyframes rather than to the animation overall, so an eased animation decelerates at every stop. Set 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?
Space the keyframe positions on a squared curve and keep the timing linear. Over the first quarter of a fall an object covers about 6% of the distance, by half way 25%, by three quarters 56%. Even spacing with an ease on top never looks right because the easing restarts at every stop.
Can I use an elastic easing on a transition?
Yes, and it behaves better there. A transition has a single segment, so the curve runs once from start to finish exactly as drawn. This is the right place for an overshoot on a hover or press.
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.