Under the Hood
Animation

Transitions vs keyframe animations: how the browser interpolates

A CSS transition or animation is a promise, not a program — you name a start and an end, and the browser fills in every frame in between by blending the two, which is exactly the kind of per-frame work the previous lesson said you don't want to be doing from JavaScript yourself.

Transitions vs keyframe animations: how the browser interpolates

The previous lesson spent a long time establishing how unforgiving a single frame is: 16.6ms, shared between your JavaScript and the browser's own rendering work, with zero partial credit for being late. If you take that seriously, the obvious next question is: do you actually have to run code every single frame just to move a box across the screen? For a huge fraction of the animation you write, the answer is no — and the reason is worth understanding mechanically, because it's the foundation everything else in this module builds on.

When you write a CSS transition or a @keyframes animation, you are not describing a loop. You're describing two (or more) known states and handing the browser a duration. The browser then does something specific, every frame, entirely on its own schedule: it computes a blend between the states and paints that blend. That process is called interpolation, and once you see it as a mechanical operation — not magic, not "the browser figures it out" — CSS animation stops being a black box.

Interpolation is arithmetic, not magic

Strip away the CSS syntax and interpolation is one formula, run once per frame:

value(t) = start + (end - start) × progress(t)

start and end are the two values you've told the browser about. progress(t) is a number from 0 to 1 that represents how far through the animation's duration the current frame falls (for now, assume it advances linearly with time — the next lesson is entirely about how that assumption bends). The browser doesn't store a list of intermediate frames anywhere; it computes value(t) fresh, every frame, from just those three numbers. Animate width from 100px to 300px over one second, and at the 250ms mark — a quarter of the way through — the browser doesn't look anything up, it computes 100 + (300 - 100) × 0.25 = 150px and lays out a box that wide. At 500ms it's 200px. At 750ms it's 250px. That's the entire mechanism.

This is also precisely the work the previous lesson described happening inside step 3 of the frame pipeline — style recalculation. The browser builds what's effectively a computed-value timeline: for the animating property, "what should the computed value be at time t," and it samples that timeline once per frame, on the compositor's or main thread's own clock, without your JavaScript being invoked at all. You supplied the two endpoints and the duration; the browser supplies every frame in between.

The hard requirement: both endpoints have to be known values

That formula only works if start and end are both real, computable numbers at the moment the animation begins. This is the mechanical reason behind a rule that trips up almost everyone at some point: you cannot transition to or from auto, or from any value the browser hasn't resolved into a concrete number yet.

Think about why. height: auto isn't a value — it's an instruction: "compute your height from content." The browser doesn't know what number that resolves to until it actually runs layout, and even then, auto isn't stored as the resolved pixel figure, it's stored as "keep asking layout." Try to write transition: height 0.3s between height: auto and height: 200px, and the browser has no start to plug into the formula — there's no fixed number sitting behind auto for it to subtract from end. So instead of interpolating, most browsers just snap: the property changes instantly, with no animation at all, because there was never a valid pair of endpoints to blend between.

The same mechanical gap explains a handful of other surprises. You can't meaningfully interpolate background-image between two different URLs — there's no defined "halfway" between two images. You can't interpolate display: none to display: block — more on that below. In every case the diagnosis is the same: ask "does the browser have two concrete, resolved values it could plug into start + (end - start) × progress?" If the answer is no, there's nothing to interpolate, and CSS falls back to an instant, discrete change.

Not every value type blends the same way

The formula start + (end - start) × progress is stated for plain numbers, but most CSS values aren't plain numbers, and the browser has a specific interpolation rule for each value type:

  • Numbers and lengths interpolate directly with the formula as written: opacity from 1 to 0, or width from 100px to 300px, blend linearly on that single scalar.
  • Colors interpolate channel by channel. rgb(255, 0, 0) to rgb(0, 0, 255) doesn't produce some perceptual "average color" — the browser runs the exact same formula independently on red, green, and blue (and alpha, if present): at the halfway point you get rgb(127.5, 0, 127.5), a muddy purple, because each channel is just being blended against its own opposite number. This is also why interpolating in different color spaces (say, oklch instead of rgb) produces visibly different midpoints for the "same" start and end color — the channels being blended are different numbers entirely.
  • Transforms are the trickiest, because transform: translateX(100px) rotate(45deg) scale(1.5) isn't one number, it's a matrix. The browser can't blend two matrices by averaging their raw entries and get a sensible result (that can produce shearing and other garbage). Instead it decomposes each matrix back into its underlying translate, rotate, scale, and skew components, interpolates each component separately using the appropriate rule (linear for translate and scale, angular for rotate), and recomposes a new matrix from the blended components, every frame. This is also why animating between two transform values with a different function order or composition can produce a visually unexpected path — decomposition has to reconcile them into comparable components first.

The unifying idea: "interpolation" isn't one algorithm, it's a family of algorithms keyed to the value's type, and the CSS specification defines exactly which rule applies to which property. Some properties, though, don't have any defined blending rule at all — those are next.

Discrete properties: no blend exists, so the browser snaps

display, visibility, and most keyword-only properties (font-family, background-repeat, and the like) have no defined intermediate value. There is no such thing as "40% of the way from display: block to display: none" — a box is either participating in layout or it isn't; there's no continuum between those two states for the browser to sample along. For properties like this, the CSS spec defines them as discrete: the browser picks a single flip point (usually 50% of the transition's duration) and swaps the value instantly at that point, with no visual blending on either side. Everything up to the flip point renders as start; everything after renders as end.

visibility gets a slightly friendlier version of this rule specifically so you can chain it with an opacity fade: transitioning visibility from visible to hidden waits until the end of the duration to flip (rather than the 50% midpoint), so transition: opacity 0.3s, visibility 0.3s fades the element out and only removes it from hit-testing once it's already invisible — without that special case, the element would stop receiving pointer events halfway through its own fade-out, before you could see that it had happened.

Two mechanisms, two triggers: transition vs @keyframes

Both transition and @keyframes/animation interpolate using exactly the machinery above. What differs is when and why the browser starts sampling that timeline, and that difference is the real distinction to hold onto.

A transition is reactive. It doesn't run on its own — it's a standing instruction that says "if this property's computed value ever changes, animate to the new value instead of snapping." The trigger is always some other state change: a class toggle, a :hover, an inline style set from JavaScript. Before that change, nothing is happening; there's no timeline being sampled at all. A transition is inherently one-shot and A→B — it has exactly two endpoints, the value the property had a moment ago and the value it has now.

.card {
  transform: translateY(0);
  transition: transform 0.25s ease-out;
}
.card:hover {
  transform: translateY(-8px);
}

Nothing animates until :hover actually changes the computed value of transform. The moment it does, the browser captures "whatever transform currently evaluates to" as start, the new hovered value as end, and starts sampling the interpolation formula for 250ms. Un-hover mid-transition and the browser doesn't rewind a recording — it captures a new start (wherever the transform's animated value currently sits, which might be partway between the original two endpoints) and a new end (translateY(0)), and starts a fresh interpolation from there. This is why transitions reverse so naturally from any point: there's no timeline object being scrubbed, just a live "what's the current value, what's the target" pair recomputed on each change.

A @keyframes animation, by contrast, is a self-contained, author-defined timeline that runs the moment it's applied, independent of any state change:

@keyframes pulse {
  0%   { transform: scale(1);    opacity: 1;   }
  50%  { transform: scale(1.15); opacity: 0.7;  }
  100% { transform: scale(1);    opacity: 1;   }
}
.badge {
  animation: pulse 1.2s ease-in-out infinite;
}

The instant .badge matches that rule, the animation starts — nothing else has to change. Internally the browser is doing the exact same interpolation between each adjacent pair of keyframes (0%→50%, then 50%→100%), just with more than two stops on the timeline and author control over extra behavior a transition doesn't have: infinite (or a fixed count) makes it loop, alternate reverses direction on each iteration, and animation-fill-mode decides whether the element holds the first or last keyframe's values before the animation starts or after it ends, rather than snapping back to whatever the base rule says.

The practical rule of thumb falls straight out of the mechanism: reach for a transition when the motion is a reaction to a state change and only ever needs two endpoints (hover, focus, open/closed toggles). Reach for @keyframes when you need a timeline that runs on its own schedule, needs more than two stops, or needs to loop — a loading spinner, a pulsing badge, an entrance animation that plays automatically on load. Both are the browser sampling the same interpolation formula; you're only choosing what starts the sampling and how many stops it has.

Where this goes next

Everything above quietly assumed progress(t) advances in lockstep with elapsed time — halfway through the duration means progress = 0.5. That's linear timing, and it's almost never what you actually want: real motion doesn't move at constant velocity, which is exactly why ease, cubic-bezier(), and steps() exist. The next lesson takes apart what a timing function actually does to that progress value before it ever reaches the interpolation formula above.

Go deeper

  • MDN — CSS animated properties The authoritative, property-by-property reference for which interpolation rule (number, color, transform, discrete) applies to each animatable CSS property.
  • W3C — Web Animations, interpolation The spec-level definition of the interpolation procedure this lesson describes informally, including how discrete properties are formally handled.
  • MDN — Using CSS animations The full @keyframes syntax including animation-fill-mode and animation-direction, the two knobs this lesson mentions but doesn't exhaustively cover.

Check yourself

Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.

  1. Write out the interpolation formula and use it to compute the value of a property animating from 50px to 150px, 40% of the way through its duration.
  2. Explain mechanically why `transition: height 0.3s` between `auto` and `300px` doesn't animate, and name two ways to fix it.
  3. Why does interpolating a color in rgb() versus oklch() produce a different midpoint color for the same start and end?
  4. Why can't the browser interpolate a `transform` matrix by averaging its raw matrix entries, and what does it do instead?
  5. What does it mean for a CSS property to be 'discrete,' and what does the browser do instead of blending for a property like `display`?
  6. A transition is 'reactive' and a @keyframes animation 'runs on its own.' What specifically triggers each one to start sampling its interpolation timeline?
  7. If you un-hover an element halfway through a transition, what exactly does the browser use as the new start value, and why doesn't it just rewind to the original starting point?