Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

CSS  Module 16 – Transitions, animations, scroll and view transitions

Transitions and @starting-style

How CSS transitions start, how easing shapes them, how interrupted transitions reverse, and how @starting-style and allow-discrete add entry and exit effects.

  • Intermediate
  • 25 minutes
  • Examples run with Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free: 25 minutes

What you will learn

  • Write transitions with a duration, a delay and an easing, including cubic-bezier(), steps() and linear()
  • Predict how an interrupted transition reverses, and how long the way back takes
  • Use @starting-style to transition an element as it appears from display none or is added to the page
  • Use transition-behavior allow-discrete to fade an element out before it leaves the layout, where browsers support it

Before you start

What you’ll learn · Before you start

What you will learn

  • Write transitions with a duration, a delay and an easing, including cubic-bezier(), steps() and linear()
  • Predict how an interrupted transition reverses, and how long the way back takes
  • Use @starting-style to transition an element as it appears from display none or is added to the page
  • Use transition-behavior allow-discrete to fade an element out before it leaves the layout, where browsers support it

Before you start

On this page

A transition makes a CSS property change gradually instead of all at once. When a value changes, because a class is added, :hover starts to match or a media query flips, the browser animates from the old value to the new one over the time you set. One declaration says what to animate and how: transition: opacity 200ms ease-out fades opacity over 200 milliseconds, slowing down near the end. A transition needs two values the element really had, so an element that appears from display: none, or that a script has just added, has nothing to start from. @starting-style fills that gap: it supplies the values to start from the first time the element is styled. And with transition-behavior: allow-discrete, display itself can take part, so an element can fade out before it leaves the layout. Real transitions, paused and sampled in three browser engines (Chromium 153, Firefox 155, WebKit 26.6), show what each easing does, how an interrupted transition comes back, and where entry and exit effects work today.

What starts a transition

Five longhand properties describe a transition. transition-property names what to animate, transition-duration how long it takes, transition-timing-function its easing, transition-delay how long to wait first, and transition-behavior whether discrete values such as display may take part. The transition shorthand sets them all, one comma-separated item per property. Inside an item the order of the two times matters: the first is the duration and the second the delay, so opacity 200ms 50ms waits 50 ms and then fades for 200 ms.

A hover and focus transition, as a shorthand and as longhands CSS · button.css
/* A button that eases into its hover and focus styles, and back out of them. */
.button {
  background-color: #1d4ed8;
  translate: 0 0;
  transition:
    background-color 150ms ease-out,
    translate 150ms ease-out;
}

.button:hover,
.button:focus-visible {
  background-color: #1e40af;
  translate: 0 -2px;
}

/* The same transition as longhands: one list per property, matched by position. */
.button-longhand {
  transition-property: background-color, translate;
  transition-duration: 150ms, 150ms;
  transition-timing-function: ease-out, ease-out;
  transition-delay: 0s, 0s;
}

The defaults explain a common surprise. transition-property starts as all, but transition-duration starts as 0s, so a rule that names only a property, transition: opacity, runs for zero seconds and changes nothing. The timing always comes from the style the element changes to: the transition values in effect after the change decide how it runs. That makes asymmetric timing easy. Put transition: opacity 120ms ease-in on a menu and transition: opacity 240ms ease-out on the menu’s open state, and it opens in 240 ms and closes in 120 ms.

Three questions in a column that decide whether a changed CSS value transitions or jumps, including the @starting-style case.A computed value changes(a class, :hover, a media query)Is the property in transition-property,with a duration or delay above 0s?no: the new value applies at onceCan the two values be interpolated?discrete ones such as display needtransition-behavior: allow-discreteno: the new value applies at onceWas the element rendered before?no (display: none, or just added):it needs a @starting-style ruleto start from, or the value jumpsA transition runs from the old value,or the starting style, to the new oneyesyesyes, or a starting style

When a style change starts a transition

Text description of the diagram

The diagram is a column of questions, asked for each property whose computed value changes, for example because a class was added, :hover started to match or a media query changed.

  1. Is the property in transition-property, with a duration or delay above 0s? If not, the new value applies at once.
  2. If yes: can the two values be interpolated? Discrete values, such as display, need transition-behavior: allow-discrete. If not, the new value applies at once.
  3. If yes: was the element rendered before the change? If it was display: none or has just been added, it needs a @starting-style rule to start from; without one, the value jumps.
  4. If it was rendered, or a starting style matched, a transition runs from the old value, or the starting style, to the new one.

Each question in the diagram comes from the specification’s rules for starting a transition. The second one catches values that can’t be blended: height: auto and a number of pixels, for example, have nothing in between to draw, so the height jumps. Chromium’s interpolate-size: allow-keywords lets auto take part, but no other engine supports it yet, so treat it as an extra.

Why transition: all starts more than you expect

all is tempting, because nothing needs updating when the styles change. It also animates everything that changes, including properties you never meant to move. A card with transition: all 300ms changed four declarations when it was selected: its padding, its border radius, its background colour and its text colour.

Counting the transitions that a selected card starts HTML · transition-all.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>What transition: all starts</title>
  <style>
    body { margin: 0; font: 16px/24px Arial, sans-serif; }
    .card { padding: 8px; border-radius: 4px; background-color: #fff; color: #111; }
    .card.selected { padding: 16px; border-radius: 12px; background-color: #dbeafe; color: #1e3a8a; }
    #all { transition: all 300ms ease-out; }
    #listed { transition: background-color 300ms ease-out, color 300ms ease-out; }
  </style>
</head>
<body>
  <div class="card" id="all">A card with transition: all</div>
  <div class="card" id="listed">A card that lists two properties</div>
  <script>
    // Select both cards: four properties change. Count the transitions each card starts.
    for (const card of document.querySelectorAll('.card')) {
      getComputedStyle(card).color;
      card.classList.add('selected');
      const names = card.getAnimations().map((a) => a.transitionProperty).sort();
      console.log(`${card.id}: ${names.length} transitions (${names.join(', ')})`);
    }
  </script>
</body>
</html>
The card has Transitions started in all three engines
transition: all 300ms ease-out 10: four padding-*, four border-*-radius, background-color, color
background-color and color listed 2

Shorthands such as padding transition as their longhands, and each of those ten transitions fires its own transitionend event, so a script waiting for “the” end of the transition runs ten times. Listing the properties keeps the motion to what the design asked for, and keeps layout properties such as padding, which make the browser recalculate the layout on every frame, out of it.

Easing: how the value moves over time

An easing function maps the share of time that has passed, from 0 to 1, to the share of the change that is done. linear keeps the two equal. The default, ease, and its relatives ease-in, ease-out and ease-in-out are cubic Bézier curves; cubic-bezier(x1, y1, x2, y2) lets you place the curve’s two control points yourself. The x values must stay between 0 and 1, because time can’t run backwards, but the y values may go beyond, and a y above 1 carries the value past its end before it settles. steps(4) moves in four equal jumps instead of smoothly, which suits a ticking hand or a sprite sheet. linear() joins a list of points with straight lines, which is enough to draw springs and bounces that a single Bézier curve can’t. All three function types are Baseline widely available.

This program works each easing out the way CSS Easing Level 2 defines it, and builds the spring’s points from a damped spring:

Progress of four easings at fixed times JavaScript · easing.mjs
// How far a 1-second transition has got at fixed times, for four easing functions, worked out the way
// CSS Easing Level 2 defines them. It also builds the spring curve as linear() points from a damped spring.
// Change the easings or the times and run it again.

// cubic-bezier(x1, y1, x2, y2): find the curve parameter s whose x is the input progress, then return its y.
function cubicBezier(x1, y1, x2, y2) {
  const coord = (a, b, s) => 3 * a * (1 - s) ** 2 * s + 3 * b * (1 - s) * s ** 2 + s ** 3;
  return (t) => {
    let lo = 0, hi = 1, s = t;
    for (let i = 0; i < 60; i++) {           // bisection: x(s) grows with s because x1 and x2 lie in [0, 1]
      if (coord(x1, x2, s) < t) lo = s; else hi = s;
      s = (lo + hi) / 2;
    }
    return coord(y1, y2, s);
  };
}

// steps(n, jump-end): the output rises in n equal steps, the last one at the very end.
const steps = (n) => (t) => (t >= 1 ? 1 : Math.floor(t * n) / n);

// linear(p0, p1, …): points spread evenly over the duration, joined by straight lines.
const linear = (points) => (t) => {
  const i = Math.min(Math.floor(t * (points.length - 1)), points.length - 2);
  const local = t * (points.length - 1) - i;
  return points[i] + (points[i + 1] - points[i]) * local;
};

// A spring that overshoots and settles: damping ratio 0.5, natural frequency 10 per duration.
const zeta = 0.5, omega = 10, omegaD = omega * Math.sqrt(1 - zeta ** 2);
const spring = (t) => 1 - Math.exp(-zeta * omega * t) * (Math.cos(omegaD * t) + (zeta * omega / omegaD) * Math.sin(omegaD * t));
const points = Array.from({ length: 21 }, (_, i) => (i === 20 ? 1 : Math.round(spring(i / 20) * 1000) / 1000));
console.log(`spring = linear(${points.join(', ')})\n`);

const easings = [
  ['ease', cubicBezier(0.25, 0.1, 0.25, 1)],          // the default: cubic-bezier(0.25, 0.1, 0.25, 1)
  ['overshoot', cubicBezier(0.3, 1.5, 0.6, 1)],       // y1 above 1 carries it past the end value
  ['steps(4)', steps(4)],
  ['spring', linear(points)],
];
const times = [0, 200, 400, 600, 800, 1000];
console.log(`${'time'.padEnd(9)}${easings.map(([name]) => name.padEnd(11)).join('')}`.trimEnd());
for (const ms of times) {
  const row = easings.map(([, ease]) => ease(ms / 1000).toFixed(2).padEnd(11)).join('');
  console.log(`${(ms + ' ms').padEnd(9)}${row}`.trimEnd());
}

Output

spring = linear(0, 0.104, 0.34, 0.61, 0.849, 1.023, 1.124, 1.162, 1.153, 1.118, 1.075, 1.034, 1.002, 0.983, 0.974, 0.974, 0.979, 0.986, 0.993, 0.999, 1)

time     ease       overshoot  steps(4)   spring
0 ms     0.00       0.00       0.00       0.00
200 ms   0.30       0.73       0.00       0.85
400 ms   0.68       1.03       0.25       1.15
600 ms   0.89       1.08       0.50       1.00
800 ms   0.98       1.03       0.75       0.98
1000 ms  1.00       1.00       1.00       1.00

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node easing.mjs

Then the page below runs the same four transitions in a browser. It moves a dot 100px, holds each transition still with pause(), sets its clock to fixed times through getAnimations() and reads how far the dot has got:

The same four easings, sampled in a browser HTML · easing-gallery.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Four easings, sampled at fixed times</title>
  <style>
    body { margin: 0; font: 16px/24px Arial, sans-serif; }
    .dot { inline-size: 24px; block-size: 24px; border-radius: 50%; background: #1d4ed8; margin: 8px; translate: 0; }
    .dot.moved { translate: 100px; }
    #ease { transition: translate 1000ms ease; }
    #overshoot { transition: translate 1000ms cubic-bezier(0.3, 1.5, 0.6, 1); }
    #steps { transition: translate 1000ms steps(4); }
    #spring {
      transition: translate 1000ms linear(0, 0.104, 0.34, 0.61, 0.849, 1.023, 1.124, 1.162, 1.153, 1.118, 1.075,
        1.034, 1.002, 0.983, 0.974, 0.974, 0.979, 0.986, 0.993, 0.999, 1);
    }
  </style>
</head>
<body>
  <div class="dot" id="ease"></div>
  <div class="dot" id="overshoot"></div>
  <div class="dot" id="steps"></div>
  <div class="dot" id="spring"></div>
  <script>
    // Start every transition, hold each one still with pause(), then move its clock to fixed times and read how far
    // the dot has gone: translate is 0px at the start and 100px at the end, so pixels ÷ 100 is the progress.
    const dots = [...document.querySelectorAll('.dot')];
    for (const dot of dots) getComputedStyle(dot).translate;   // the start value is computed first
    for (const dot of dots) dot.classList.add('moved');
    for (const dot of dots) {
      const [transition] = dot.getAnimations();
      transition.pause();
      const samples = [0, 200, 400, 600, 800, 1000].map((ms) => {
        transition.currentTime = ms;
        return (parseFloat(getComputedStyle(dot).translate) / 100).toFixed(2);
      });
      console.log(`${dot.id}: ${samples.join(', ')}`);
    }
  </script>
</body>
</html>

All three engines logged exactly the program’s numbers, to two decimal places. Read down a column to see the shape of each curve. ease has already done 30 % of the move after 20 % of the time, and 68 % after 40 %. The overshooting curve passes the end after about 0.37 seconds, peaks at 108 % around 0.56 seconds and then comes back. steps(4) stays at 0 until the first quarter is over. The spring peaks at about 116 % after 0.36 seconds and has settled by the end, which is why springs feel physical: they arrive early and then correct.

Cubic Bezier & CSS Easing Generator Drag a curve's control points, compare it with ease, and copy a spring or bounce as linear().

When a transition is interrupted

Users rarely wait for a transition to finish. When a value changes while its transition is still running, a new transition starts from wherever the value has got to, so nothing jumps. One case gets special treatment: going back to where the running transition started, such as a pointer that leaves a menu before it has finished opening. Then the new transition is shortened by the share of the way that had been covered, measured in the value, not in time.

Three panels interrupted 300 ms into a 1000 ms transition HTML · reverse.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>An interrupted transition goes back faster</title>
  <style>
    body { margin: 0; font: 16px/24px Arial, sans-serif; }
    .panel { inline-size: 120px; block-size: 40px; background: #0f766e; translate: 0; }
    .panel.open { translate: 200px; }
    #linear { transition: translate 1000ms linear; }
    #ease { transition: translate 1000ms ease; }
    #elsewhere { transition: translate 1000ms linear; }
  </style>
</head>
<body>
  <div class="panel" id="linear"></div>
  <div class="panel" id="ease"></div>
  <div class="panel" id="elsewhere"></div>
  <script>
    // Each panel starts a 1000 ms transition to 200px. At 300 ms its clock is held, and the style changes again:
    // back to 0 for the first two panels (a reversal), and on to 100px for the third. The new transition's duration
    // and start value show what the browser did.
    for (const panel of document.querySelectorAll('.panel')) {
      getComputedStyle(panel).translate;
      panel.classList.add('open');
      const [first] = panel.getAnimations();
      first.pause();
      first.currentTime = 300;
      const reached = Math.round(parseFloat(getComputedStyle(panel).translate));
      if (panel.id === 'elsewhere') panel.style.translate = '100px';
      else panel.classList.remove('open');
      const [second] = panel.getAnimations();
      const duration = second.effect.getComputedTiming().duration;
      console.log(`${panel.id}: at 300 ms it was at ${reached}px; the new transition runs ${Math.round(duration)} ms`);
    }
  </script>
</body>
</html>
Panel at 300 ms Sent Runs for
linear, 60px in back 300 ms
ease, 103px in back 513 ms
linear, 60px in on to 100px 1000 ms

Each panel was on its way to 200px, and all three engines agreed. The ease panel is the instructive one: it had run for only 300 ms, but ease had already covered 51 % of the distance, so the way back takes 513 ms, longer than the way out. With a fast-starting easing, a quick hover in and out closes more slowly than it opened; a shorter duration on the closing state, as above, keeps it crisp. The exercise below asks you to work out these factors, including a second reversal.

Animating an element as it appears: @starting-style

Transitions compare the style before a change with the style after it. An element that was display: none, or that has just been inserted, has no style before the change, so normally it appears at its final values with no transition at all. @starting-style gives such an element a starting style: the rules inside it apply only for that first comparison, and never afterwards. Here is a toast that slides up and fades in, and fades out again when it closes:

A toast with an entry and an exit CSS · toast.css
/* A toast that slides up and fades in when it opens, and fades out before it leaves the layout. */
.toast {
  display: none;
  opacity: 0;
  translate: 0 1rem;
  transition:
    opacity 200ms ease-out,
    translate 200ms ease-out,
    display 200ms allow-discrete;   /* keeps display: block until the fade-out ends */
}

.toast.is-open {
  display: block;
  opacity: 1;
  translate: 0 0;
}

/* The values to start from when the toast goes from display: none to being shown. */
@starting-style {
  .toast.is-open {
    opacity: 0;
    translate: 0 1rem;
  }
}

/* For people who ask their system for less motion: the toast still fades, but nothing slides. */
@media (prefers-reduced-motion: reduce) {
  .toast,
  .toast.is-open {
    translate: none;
  }
}
Opening and closing the toast, with and without @starting-style HTML · toast.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>A toast with @starting-style and allow-discrete</title>
  <link rel="stylesheet" href="toast.css">
  <style>
    body { margin: 0; font: 16px/24px Arial, sans-serif; }
    .toast { padding: 12px; background: #111827; color: #fff; }
    .plain { display: none; opacity: 0; transition: opacity 200ms ease-out; }
    .plain.is-open { display: block; opacity: 1; }
  </style>
</head>
<body>
  <div class="toast" id="toast" role="status">Saved. <button type="button">Undo</button></div>
  <div class="plain" id="plain" role="status">Saved, with no @starting-style.</div>
  <script>
    const names = (el) => el.getAnimations().map((a) => a.transitionProperty).sort().join(', ') || 'none';
    const toast = document.getElementById('toast');
    const plain = document.getElementById('plain');

    // Opening: display none → block. Without a starting style there is nothing to transition from.
    plain.classList.add('is-open');
    console.log(`open, no @starting-style: transitions ${names(plain)}`);
    toast.classList.add('is-open');
    console.log(`open: transitions ${names(toast)}`);
    for (const a of toast.getAnimations()) { a.pause(); a.currentTime = 100; }
    console.log(`open, at 100 ms: opacity ${Number(getComputedStyle(toast).opacity).toFixed(2)}`);
    for (const a of toast.getAnimations()) a.finish();

    // Closing: block → none. With allow-discrete, display stays block until the end of its 200 ms transition.
    toast.classList.remove('is-open');
    console.log(`close: transitions ${names(toast)}`);
    for (const a of toast.getAnimations()) { a.pause(); a.currentTime = 100; }
    const style = getComputedStyle(toast);
    const button = toast.querySelector('button');
    button.focus();
    console.log(`close, at 100 ms: display ${style.display}, opacity ${Number(style.opacity).toFixed(2)}, ` +
      `${toast.getClientRects().length ? 'still drawn' : 'gone'}, ` +
      `Undo ${document.activeElement === button ? 'can' : 'cannot'} take focus`);
  </script>
</body>
</html>
What the page did All three engines
open, no @starting-style no transition
open, with it opacity and translate transitions
opacity 100 ms into the opening 0.68

Without a starting style nothing ran, in any engine. With it, both properties transitioned from the values in the @starting-style block. @starting-style is Baseline newly available. Its rules cascade like any other rules, with no extra priority, so give them the same selector and specificity as the rule they start from, or nest the block inside that rule. It works the same way for an element a script appends, a popover that opens and a dialog shown with showModal().

Leaving the layout: display and allow-discrete

Closing is the harder half. The toast’s closed state is display: none, and an element that stops being rendered loses its running transitions at once, so the fade-out has nowhere to play. display is a discrete property: it has no values in between, and transitions ignore discrete properties unless transition-behavior: allow-discrete is set. With it, CSS Display Level 4 adds a rule for display: during a transition between none and another value, the element keeps the other value for the whole duration, so it stays visible until the fade-out has finished. It also asks that the element be inert meanwhile, so nobody can click or focus a toast that is on its way out.

Measured 100 ms into closing:

  • Chromium 153: the toast was still shown, at opacity 0.32, and the Undo button could not take focus.
  • Firefox 155: the toast was already gone.
  • WebKit 26.6: the toast was still shown, at opacity 0.32, but the Undo button could take focus.

Chromium played the exit, with three transitions running, display, opacity and translate, and made the toast inert, as specified. Firefox removed the toast at once, with no transitions at all, which matches its support: transitioning display has limited availability, shipped in Chrome 117 and Safari 18 and not yet in Firefox, while transition-behavior itself is newly available. Playwright’s WebKit build played the exit but still let the Undo button take focus, so don’t rely on the browser for inertness: set the inert attribute in the script that closes the toast, which is Baseline widely available, and remove it when the toast opens again. Design every exit so that losing the animation costs nothing; in Firefox the toast simply disappears, which is fine. Popovers and modal dialogs need one more property, overlay, to stay in the top layer during their exit, and only Chromium supports it.

Motion that respects the user

Movement that someone didn’t start can bring on dizziness, nausea and headaches in people with vestibular (inner ear) disorders. Operating systems have a setting for less motion, and CSS reads it with @media (prefers-reduced-motion: reduce). The toast’s stylesheet ends with such a block: the toast still fades, but translate is the same in every state, so nothing slides. Run with Playwright’s reduced-motion emulation, the opening started only an opacity transition in all three engines. WCAG 2.2’s success criterion 2.3.3, Animation from Interactions (level AAA), asks that motion started by an interaction can be turned off unless it is essential, and the reduced-motion setting is the usual way to offer that. Fades and colour changes are usually fine to keep; large movement, zooming and parallax are the effects to remove.

Practise: easing and reversals

Exercise · Medium · JavaScript

Work out steps(), linear() and a reversed transition

Browsers turn a transition's elapsed time into progress with its easing function, and shorten a transition that is sent back where it came from. Write three functions in timing.mjs that follow CSS Easing Level 2 and CSS Transitions, and export them. Every input progress t is between 0 and 1.

stepsAt(t, steps, position = 'jump-end') returns the output of steps(steps, position). The current step is Math.floor(t * steps), plus one for jump-start and jump-both. The number of jumps is steps for jump-start and jump-end, steps - 1 for jump-none and steps + 1 for jump-both; the step is never more than the number of jumps, and the output is step ÷ jumps. start means jump-start and end means jump-end.

linearAt(t, stops) returns the output of a linear() function. Each stop is [output] or [output, percent], like linear(0, 0.25 75%, 1) written as [[0], [0.25, 75], [1]]:

- A stop's input is its percent ÷ 100, but never less than the largest input before it. - A first stop without a percent has input 0, and a last stop without one has input 1 (or the largest input so far, if that is bigger). - Stops left without an input share the space between their neighbours evenly. - To evaluate, take the last point whose input is at most t (never the last point itself) and the point after it, and interpolate in a straight line between them. When both have the same input, return the second one's output.

reversal(duration, output, oldFactor = 1) returns { factor, duration } for a transition that reverses a running one: output is the old transition's eased progress when it was interrupted, oldFactor its own shortening factor. The factor is the absolute value of output × oldFactor + 1 − oldFactor, clamped to the range 0 to 1, and the new duration is duration × factor.

The sample tests import the three functions from timing.mjs and run in your browser.

Starter code · timing.mjs

/** The output of steps(steps, position) at input progress t. */
export function stepsAt(t, steps, position = 'jump-end') {
  // Replace this line with your code.
  return t;
}

/** The output of linear(…) at input progress t; stops are [output] or [output, percent]. */
export function linearAt(t, stops) {
  // Replace this line with your code.
  return t;
}

/** The shortening factor and duration of a transition that reverses a running one. */
export function reversal(duration, output, oldFactor = 1) {
  // Replace this line with your code.
  return { factor: 1, duration };
}
The sample tests · timing.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { stepsAt, linearAt, reversal } from './timing.mjs';

const r = (x) => Math.round(x * 1000) / 1000;

test('steps(4) rises at the end of each quarter', () => {
  assert.deepEqual([0, 0.2, 0.25, 0.6, 0.99, 1].map((t) => stepsAt(t, 4)), [0, 0, 0.25, 0.5, 0.75, 1]);
  assert.deepEqual([0, 0.2, 0.6, 1].map((t) => stepsAt(t, 4, 'end')), [0, 0, 0.5, 1]);
});

test('jump-start rises at once, and never goes past 1', () => {
  assert.deepEqual([0, 0.2, 0.75, 1].map((t) => stepsAt(t, 4, 'jump-start')), [0.25, 0.25, 1, 1]);
  assert.deepEqual([0, 0.5].map((t) => stepsAt(t, 2, 'start')), [0.5, 1]);
});

test('jump-none and jump-both', () => {
  assert.deepEqual([0, 0.3, 0.5, 0.99, 1].map((t) => r(stepsAt(t, 5, 'jump-none'))), [0, 0.25, 0.5, 1, 1]);
  assert.deepEqual([0, 0.4, 1].map((t) => stepsAt(t, 3, 'jump-both')), [0.25, 0.5, 1]);
});

test('linear() with evenly spread stops', () => {
  assert.equal(r(linearAt(0.25, [[0], [0.25], [1]])), 0.125);
  assert.equal(r(linearAt(0.75, [[0], [0.25], [1]])), 0.625);
  assert.equal(linearAt(1, [[0], [1]]), 1);
});

test('linear() with percentages, and inputs that go backwards', () => {
  assert.equal(r(linearAt(0.5, [[0], [0.25, 75], [1]])), 0.167);
  assert.equal(r(linearAt(0.9, [[0], [0.25, 75], [1]])), 0.7);
  assert.equal(r(linearAt(0.15, [[0, 20], [0.5, 10], [1]])), 0.5);
  assert.equal(r(linearAt(0.6, [[0, 20], [0.5, 10], [1]])), 0.75);
});

test('linear() with a flat part and a spring that overshoots', () => {
  assert.equal(linearAt(0.5, [[0], [0.25, 25], [0.25, 75], [1]]), 0.25);
  assert.equal(r(linearAt(0.4, [[0], [1.2, 40], [0.95, 70], [1]])), 1.2);
  assert.equal(r(linearAt(0.55, [[0], [1.2, 40], [0.95, 70], [1]])), 1.075);
});

test('a reversed transition is shortened by the progress it had made', () => {
  const plain = reversal(1000, 0.3);
  assert.deepEqual(Object.keys(plain).sort(), ['duration', 'factor']);
  assert.equal(r(plain.factor), 0.3);
  assert.equal(Math.round(plain.duration), 300);
  const ease = reversal(1000, 0.513);
  assert.equal(r(ease.factor), 0.513);
  assert.equal(Math.round(ease.duration), 513);
});

test('a second reversal uses the old factor, and the factor stays between 0 and 1', () => {
  const again = reversal(1000, 0.5, 0.3);
  assert.equal(r(again.factor), 0.85);
  assert.equal(Math.round(again.duration), 850);
  assert.equal(reversal(400, 1.2).factor, 1);
  assert.equal(r(reversal(400, -0.2).factor), 0.2);
  assert.equal(Math.round(reversal(400, -0.2).duration), 80);
});
A hint

For linearAt, build the list of points first, each { input, output }, with input set to null where the rules leave it open. Then walk the list: when you meet a run of null inputs, find the next point that has one and spread the run evenly between the two. Only then look up t.

The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Check yourself

6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 6 What does transition: opacity 300ms 100ms do when the opacity changes?

    Choose one answer.

    Show the answer to question 1

    Answer: It waits 100 ms, then fades over 300 ms

    Inside one item of the shorthand, the first time is the duration and the second is the delay. So the fade starts 100 ms after the change and lasts 300 ms.

  2. Question 2 of 6 A rule says transition: opacity, and the opacity changes on hover. What happens?

    Choose one answer.

    Show the answer to question 2

    Answer: The opacity changes at once, because the duration defaults to 0s

    transition-duration starts as 0s, and a transition only starts when its duration plus its delay is above 0s. Naming the property is not enough; add a time, such as transition: opacity 200ms.

  3. Question 3 of 6 A 600 ms transition with linear easing is sent back to its start value 150 ms after it began. How long does the transition back take?

    Type a number.

    Show the answer to question 3

    Answer: 150 ms

    A reversed transition is shortened by the share of the change already covered. With linear easing that share equals the share of time, 150 ÷ 600 = 0.25, so the way back takes 600 × 0.25 = 150 ms. With ease the share covered after a quarter of the time is larger, and the way back would take longer.

  4. Question 4 of 6 A panel goes from display: block to display: none when it closes. Which of these does it need so that it fades out first, in browsers that support it?

    Choose every answer that is right.

    Show the answer to question 4

    Answer:

    • opacity: 0 in the closed state, and opacity listed in the transition
    • display listed in the transition with allow-discrete

    The fade needs an opacity transition to a closed value. display must take part as a discrete transition, with transition-behavior: allow-discrete, so that the panel stays displayed until the fade ends. @starting-style is for the entry, when the panel appears; all would not add allow-discrete.

  5. Question 5 of 6 When do the rules inside @starting-style affect an element?

    Choose one answer.

    Show the answer to question 5

    Answer: Only when it has no style from the previous change, because it was not rendered or has just been added

    A starting style is used in place of the missing before-change style: for an element that was display: none, or has just been inserted. Later changes compare the real styles before and after, and @starting-style plays no part.

  6. Question 6 of 6 Which of these easing functions is invalid?

    Choose one answer.

    Show the answer to question 6

    Answer: cubic-bezier(1.2, 0, 0.5, 1)

    The x values of a Bézier easing are moments in time and must stay between 0 and 1, so 1.2 makes the whole declaration invalid. The y values may go beyond 0 and 1, which is how the second curve overshoots, and linear() outputs above 1 are allowed too.

Interview questions

Fresher · Why doesn’t transition: opacity fade an element that appears with display: block?

A transition compares the element’s style before a change with its style after it. An element that was display: none was not rendered, so it has no style before the change, and the browser applies the final opacity at once. Two fixes exist. @starting-style gives the element a style to start from on that first change, for example @starting-style { .menu.open { opacity: 0; } }, and it is Baseline newly available. The older fix is to show the element first and change opacity a frame later, with a script. Going the other way, to display: none, also needs display in the transition with transition-behavior: allow-discrete, so that the element stays displayed until the fade has finished; that part works in Chromium and Safari but not yet in Firefox, where the element just disappears.

Key takeaways

  • A transition runs when a computed value changes and the property is listed with a duration above 0s; the transition values of the new style control it.
  • In the shorthand the first time is the duration and the second the delay. transition: all starts a transition for every longhand that changes, ten for four declarations in the measured card.
  • ease, cubic-bezier(), steps() and linear() map time to progress; y values above 1 overshoot, and linear() points can draw springs. All three engines matched the calculated values.
  • A transition sent back where it came from is shortened by the share of the distance it had covered, so with ease the way back can take longer than the way out.
  • @starting-style gives an appearing element values to transition from. Exits to display: none need transition-behavior: allow-discrete, which plays in Chromium and Safari but not in Firefox.
  • Under prefers-reduced-motion: reduce, keep fades and remove movement.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.