CSS Transition Timing Functions: ease, cubic-bezier(), steps() and linear()

A transition that uses the right duration but the wrong timing function still feels wrong. The timing function controls how progress is distributed across the elapsed time, and that distribution is what the eye reads as "weight," "snap," or "mechanical." This guide, part of the CSS Transition Fundamentals section, isolates the four ways CSS expresses easing — the named keywords, cubic-bezier(), steps(), and the newer linear() — explains the perceptual differences between them, and shows how to package easing as reusable tokens so an entire interface shares one motion language.

The scenario is narrow but common: you have a working transition, motion is smooth, durations are tuned, yet hovers feel sluggish and panels feel cheap. The fix is almost never the duration. It is the curve.


Why the timing function matters more than the duration

Duration answers "how long," but the timing function answers "where is the element at each moment in between." Two transitions of identical length can feel completely different because one front-loads its movement and the other back-loads it. Human perception is tuned to physical motion: objects in the real world accelerate from rest and decelerate into a stop. A curve that ignores that — a constant-velocity linear ramp — reads as robotic precisely because nothing physical moves that way.

CSS gives you a declarative way to encode these curves directly on transition-timing-function (or as the third value of the transition shorthand). There is no JavaScript and no animation loop; the browser interpolates on the compositor where possible. The only judgement call is which curve communicates the intent of the interaction. Entrances that should feel inviting want a decelerating curve (slow to a stop). Exits that should feel decisive want an accelerating curve (speed away). Symmetric state toggles want something balanced like ease-in-out.

The accessibility tradeoff is the same as for any motion: a more dramatic curve (one with overshoot) draws more attention, which is exactly what you sometimes want and exactly what some users cannot tolerate. Anything you do here must still collapse under reduced motion preferences, so treat expressive easing as an enhancement layered on top of a functional, instant baseline.


The shape of each curve

The diagram below plots progress (y, 0 to 1) against elapsed time (x, 0 to 1) for the curves you reach for most. A straight diagonal is linear; the S-curve is ease/ease-in-out; the curve that bulges above the diagonal early decelerates (ease-out).

Easing curves on a progress-over-time graph An x/y graph where x is elapsed time and y is animation progress, comparing a straight linear line, an S-shaped ease curve, and a fast-then-slow ease-out cubic-bezier. Progress over time time (0 to 1) progress linear ease ease-out 0,0 1,1

The named keywords are just shorthands for cubic-bezier() values: linear is cubic-bezier(0,0,1,1), ease is cubic-bezier(0.25, 0.1, 0.25, 1), ease-in is cubic-bezier(0.42, 0, 1, 1), ease-out is cubic-bezier(0, 0, 0.58, 1), and ease-in-out is cubic-bezier(0.42, 0, 0.58, 1). Knowing the keyword equivalents lets you start from a familiar feel and nudge it rather than guessing at four numbers cold.


A complete working implementation: easing tokens applied to one component

The pattern below registers a small set of named easing tokens as custom properties, then drives a card's hover and a panel's expand from those tokens. Because the curves live in one place, retuning the motion of the whole interface is a single edit. This also connects to the CSS Custom Properties Architecture for the broader token system.

<div class="demo">
  <button class="card" type="button">
    <span class="card__label">Hover or focus me</span>
  </button>

  <details class="panel">
    <summary class="panel__summary">Details (steps reveal)</summary>
    <div class="panel__body">Content that ticks open in discrete steps.</div>
  </details>
</div>
:root {
  /* Easing tokens: name the intent, not the numbers */
  --ease-standard: cubic-bezier(0.4, 0, 0.2, 1);   /* balanced UI default */
  --ease-decelerate: cubic-bezier(0, 0, 0.2, 1);   /* entrances: slow to a stop */
  --ease-accelerate: cubic-bezier(0.4, 0, 1, 1);   /* exits: speed away */
  --ease-overshoot: cubic-bezier(0.34, 1.56, 0.64, 1); /* playful spring */
  --ease-tick: steps(6, jump-end);                 /* discrete ticking */

  /* Duration tokens pair with the curves */
  --dur-fast: 160ms;
  --dur-base: 240ms;
}

.card {
  border: 1px solid currentColor;
  border-radius: 12px;
  padding: 1rem 1.25rem;
  background: transparent;
  /* The third value of the shorthand IS the timing function */
  transition:
    transform var(--dur-base) var(--ease-overshoot),
    box-shadow var(--dur-fast) var(--ease-standard);
}

.card:hover,
.card:focus-visible {
  /* Overshoot makes the lift feel springy rather than mechanical */
  transform: translateY(-6px) scale(1.02);
  box-shadow: 0 10px 24px rgb(0 0 0 / 0.18);
}

.panel__body {
  /* steps() holds each frame, producing a ticking reveal instead of a glide */
  overflow: hidden;
  max-height: 0;
  transition: max-height var(--dur-base) var(--ease-tick);
}

/* native disclosure: when open, animate height via a generous max-height */
.panel[open] .panel__body {
  max-height: 200px;
  transition-timing-function: var(--ease-decelerate);
}

/* Always provide an instant baseline for reduced-motion users */
@media (prefers-reduced-motion: reduce) {
  .card,
  .panel__body {
    transition-duration: 0.01ms;
  }
}

Every interactive element references a token, so the four-number Bezier definitions never sprout copies across the codebase. Swapping --ease-overshoot for --ease-standard instantly de-emphasises every springy element at once.


The key technique: a Bezier is a position-vs-time map

The single idea that makes cubic-bezier(x1, y1, x2, y2) predictable is that the curve maps time on the x axis to progress on the y axis. The two control points pull the curve toward themselves. A control point with a large y relative to its x makes progress race ahead of time (fast start); a control point with a small y makes progress lag behind time (slow start). Pushing y2 above 1 forces the curve past full progress and back down — that is the mathematical source of overshoot in cubic-bezier(0.34, 1.56, 0.64, 1).

The hard constraint is that both x values must stay within 0 and 1. The x axis is time, and time cannot run backwards; an x outside that range produces an invalid curve that the browser rejects. The y values, by contrast, are free to exceed the range, which is exactly how bounce and snap-back are expressed.


Variation: spring-like motion with linear()

cubic-bezier() has exactly two control points, so it can describe one acceleration and one deceleration but not a true multi-bounce spring. The linear() function fills that gap by accepting a list of progress stops, letting you approximate any curve as a piecewise-linear sequence — including a damped bounce.

:root {
  /* A damped spring approximated by linear() progress stops */
  --ease-spring: linear(
    0, 0.18 8%, 0.55 18%, 0.92 30%, 1.08 42%,
    1 55%, 0.97 65%, 1 80%, 1
  );
}

.toast {
  transition: transform var(--dur-base) var(--ease-spring);
  transform: translateY(-120%);
}
.toast[data-visible] {
  transform: translateY(0);
}

/* Graceful fallback: older engines ignore the unknown function and
   fall back to the previously declared (or default) timing function */
@supports not (transition-timing-function: linear(0, 1)) {
  .toast { transition-timing-function: var(--ease-overshoot); }
}

The toast slides in, slightly overshoots past its resting position, settles back, and gently re-corrects — motion that genuinely reads as spring physics, achieved with no JavaScript and no keyframes.


Browser support note

The named keywords, cubic-bezier(), and steps() have been supported in every evergreen browser for over a decade and need no fallback. The linear() easing function is newer: it shipped in Chrome and Edge 113+, Safari 17.2+, and Firefox 112+. Because an unrecognised timing function is simply ignored, a @supports not (transition-timing-function: linear(0, 1)) guard (as above) lets you supply a cubic-bezier() substitute for any engine that predates it without breaking the transition.


FAQ

What is the difference between ease and linear easing?linear moves at a constant rate from start to finish, which reads as mechanical because nothing physical moves at a fixed velocity. ease starts slow, accelerates through the middle, and slows into the end, matching real-world acceleration and deceleration, so it feels natural for most UI motion.

When should I use steps() instead of a smooth curve? Use steps() when you want discrete, snapping motion rather than continuous interpolation. It suits sprite-sheet animations, typewriter and ticking effects, and segmented progress indicators where each frame should hold briefly before jumping to the next value.

Can cubic-bezier() create a bounce or overshoot effect? Yes. If a control point's y value rises above 1 the curve overshoots its target before settling, and if it drops below 0 it undershoots and snaps back. Keep both x values within 0 to 1, since x represents time and cannot run backwards.

What does the linear() function add over cubic-bezier()?linear() takes a list of progress stops, so it can approximate multi-segment curves such as damped springs and bounces that a single two-control-point cubic Bezier cannot express. It is supported in Chrome and Edge 113+, Safari 17.2+, and Firefox 112+.


Related articles

More pages in the same section.