CSS transition, one property at a time

A transition animates a CSS value from the old one to the new one whenever it changes. Five properties control it, and one event tells you when it is done.

A CSS transition animates a property from its old value to its new value whenever that value changes. You write it once on the element, for example transition: opacity 300ms ease, and every later change to opacity slides over 300 milliseconds instead of snapping.

Try it. Change the duration, the delay and the timing function, then press Move. The line under the button is the CSS that is running.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CSS transition playground</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .track { position: relative; height: 56px; border-radius: 12px; background: #fff; box-shadow: inset 0 0 0 1px #dde1e7; }
  .ball {
    position: absolute; top: 8px; left: 8px;
    width: 40px; height: 40px; border-radius: 50%; background: #2563eb;
    /* the whole lesson in one line: property, duration, timing function, delay */
    transition: left 600ms ease 0ms;
  }
  .ball.on { left: calc(100% - 48px); }
  .controls { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 10px 14px; margin: 14px 0 12px; font-size: 13px; }
  label { display: grid; gap: 4px; }
  select, input { font: inherit; }
  button { font: inherit; font-weight: 600; padding: 8px 16px; border: 0; border-radius: 8px; background: #1d2330; color: #fff; cursor: pointer; }
  code { display: block; margin-top: 10px; padding: 8px 10px; border-radius: 8px; background: #fff; font: 13px ui-monospace, Consolas, monospace; overflow-wrap: anywhere; }
  #log { margin: 8px 0 0; font-size: 13px; color: #0f5132; min-height: 1.4em; }
</style>
</head>
<body>
<div class="track"><div class="ball" id="ball"></div></div>

<div class="controls">
  <label>Duration: <span id="durOut">600ms</span>
    <input id="dur" type="range" min="100" max="2000" step="100" value="600"></label>
  <label>Delay: <span id="delOut">0ms</span>
    <input id="del" type="range" min="0" max="1000" step="100" value="0"></label>
  <label>Timing function
    <select id="ease">
      <option>ease</option><option>linear</option><option>ease-in</option>
      <option>ease-out</option><option>ease-in-out</option>
      <option>cubic-bezier(.34, 1.56, .64, 1)</option>
      <option>steps(5)</option>
    </select></label>
</div>

<button id="go">Move</button>
<code id="css"></code>
<p id="log"></p>

<script>
  const ball = document.getElementById('ball');
  const dur = document.getElementById('dur'), del = document.getElementById('del'), ease = document.getElementById('ease');
  const log = document.getElementById('log');

  function update() {
    const t = `left ${dur.value}ms ${ease.value} ${del.value}ms`;
    ball.style.transition = t;  // same as writing it in the stylesheet
    document.getElementById('css').textContent = `transition: ${t};`;
    document.getElementById('durOut').textContent = dur.value + 'ms';
    document.getElementById('delOut').textContent = del.value + 'ms';
  }
  [dur, del, ease].forEach((el) => el.addEventListener('input', update));
  update();

  let started = 0;
  document.getElementById('go').addEventListener('click', () => {
    started = performance.now();
    log.textContent = 'Moving...';
    ball.classList.toggle('on');  // change the value; the transition animates it
  });

  // fires once per property when the transition finishes
  ball.addEventListener('transitionend', (e) => {
    const ms = Math.round(performance.now() - started);
    log.textContent = `transitionend: ${e.propertyName}, ${e.elapsedTime}s of motion, ${ms}ms after the click`;
  });
</script>
</body>
</html>
A ball, one transition line and a transitionend listener. Edit the code and the example reruns.

The transition does not start anything by itself. Something else changes the value: a class toggled by a click, a :hover rule, or a style set from JavaScript. The transition only decides how the change looks.

The five properties behind the shorthand

transition is a shorthand. Each part has its own property, and each has a default.

Property Default What it controls
transition-property all Which properties animate
transition-duration 0s How long the change takes
transition-timing-function ease How speed varies along the way
transition-delay 0s How long to wait before starting
transition-behavior normal Whether properties like display take part

The duration default explains a common surprise. With 0s, every property already "transitions", just instantly. Nothing moves until you give a duration.

The four common parts of the shorthand, and where each one falls in time.
The four common parts of the shorthand, and where each one falls in time.

Order inside the shorthand matters only for times. The first time is the duration and the second is the delay. The property name and the timing function can sit anywhere.

Every time needs a unit, ms or s. transition: opacity 300 is invalid, and the browser drops the whole declaration.

Several properties, and staggered delays

Separate transitions with commas. Each one gets its own duration, timing and delay.

.card {
  transition:
    transform 200ms ease-out,
    box-shadow 300ms ease-out,
    background-color 150ms linear;
}

You can also write the long form with lists. The values line up by position: the first duration goes with the first property.

.card {
  transition-property: opacity, transform;
  transition-duration: 200ms, 400ms;
}

transition: all 300ms is shorter, but it animates every property that changes, including ones you did not plan for. Naming the properties keeps the motion you meant and nothing else.

transition-delay waits before the value starts moving. It is useful for staggering a list, where each item gets a slightly longer delay than the one before.

A negative delay starts the transition partway through. With transition: opacity 1s -0.5s, the fade begins as if half a second had already passed.

Keep motion short for people who have asked their system for less of it. The prefers-reduced-motion media query lets you shorten or remove transitions for them.

For where to put the rule on hover effects, and how to use different speeds on the way in and out, see CSS hover transition.

Timing functions: ease, cubic-bezier and steps

The timing function decides how the time is spent. Every dot below takes the same 1.6 seconds. Only the timing function differs.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Timing functions side by side</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .lane { display: grid; grid-template-columns: 118px 1fr; align-items: center; gap: 8px; margin-bottom: 6px; }
  .lane span { font: 12px ui-monospace, Consolas, monospace; overflow-wrap: anywhere; }
  .track { position: relative; height: 34px; border-radius: 8px; background: #fff; box-shadow: inset 0 0 0 1px #dde1e7; }
  .dot {
    position: absolute; top: 5px; left: 5px; width: 24px; height: 24px; border-radius: 50%; background: #2563eb;
    transition: left 1.6s;  /* same duration for all; only the timing function differs */
  }
  .run .dot { left: calc(100% - 29px); }
  button { font: inherit; font-weight: 600; padding: 8px 16px; border: 0; border-radius: 8px; background: #1d2330; color: #fff; cursor: pointer; margin-top: 6px; }
</style>
</head>
<body>
<div id="race">
  <div class="lane"><span>linear</span><div class="track"><i class="dot" style="transition-timing-function: linear"></i></div></div>
  <div class="lane"><span>ease</span><div class="track"><i class="dot" style="transition-timing-function: ease"></i></div></div>
  <div class="lane"><span>ease-in</span><div class="track"><i class="dot" style="transition-timing-function: ease-in"></i></div></div>
  <div class="lane"><span>ease-out</span><div class="track"><i class="dot" style="transition-timing-function: ease-out"></i></div></div>
  <div class="lane"><span>ease-in-out</span><div class="track"><i class="dot" style="transition-timing-function: ease-in-out"></i></div></div>
  <div class="lane"><span>cubic-bezier(.34, 1.56, .64, 1)</span><div class="track"><i class="dot" style="transition-timing-function: cubic-bezier(.34, 1.56, .64, 1)"></i></div></div>
  <div class="lane"><span>steps(5)</span><div class="track"><i class="dot" style="transition-timing-function: steps(5)"></i></div></div>
</div>
<button id="go">Run</button>

<script>
  // every dot starts and ends together; watch how each one spends the 1.6 seconds
  document.getElementById('go').addEventListener('click', () => {
    document.getElementById('race').classList.toggle('run');
  });
</script>
</body>
</html>
Seven timing functions, one duration. Press Run to send them out and again to bring them back.

The five keywords are fixed curves. Four of them are cubic-bezier curves with set numbers.

Keyword Same as Feels like
linear cubic-bezier(0, 0, 1, 1) Machine-steady
ease cubic-bezier(0.25, 0.1, 0.25, 1) Quick start, soft landing
ease-in cubic-bezier(0.42, 0, 1, 1) Slow start, sudden stop
ease-out cubic-bezier(0, 0, 0.58, 1) Fast start, gentle stop
ease-in-out cubic-bezier(0.42, 0, 0.58, 1) Slow at both ends
Each curve plots progress against time. Steeper means faster.
Each curve plots progress against time. Steeper means faster.

cubic-bezier(x1, y1, x2, y2) takes two control points. The x values are time and must stay between 0 and 1, or the declaration is invalid. The y values can go outside that range.

A y above 1 carries the value past its target before it settles, which reads as a small bounce.

steps(n) splits the change into n jumps with no motion in between. It suits a counter, a sprite sheet or a typing effect.

A second argument picks where the jumps fall:

  • jump-end (default, or end): holds the start value first.
  • jump-start (or start): jumps at once, so the start value never shows.
  • jump-none: shows both the start and end values as steps.
  • jump-both: jumps at the start and at the finish.

step-start and step-end are shortcuts for steps(1, jump-start) and steps(1, jump-end).

Fading in and out of display: none

display is a discrete property. It has no halfway point between none and block, so by default it flips instantly. On the way out, the element disappears at once and the fade on opacity never shows.

Without allow-discrete, display: none wins on the first frame. With it, display waits for the fade.
Without allow-discrete, display: none wins on the first frame. With it, display waits for the fade.

Two additions fix it:

  1. transition-behavior: allow-discrete, or allow-discrete in the shorthand, lets display join in. On the way to none, it waits for the fade.

  2. @starting-style gives the browser a starting value for the way in. An element coming out of display: none has no previous style, so without this rule it appears at full opacity.

.panel {
  display: none;
  opacity: 0;
  transition: opacity .35s, display .35s allow-discrete;
}
.panel.open { display: block; opacity: 1; }

@starting-style {
  .panel.open { opacity: 0; }
}

Open and close the panel, then tick the box to see the same panel without the two additions.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Fade in and out from display: none</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .bar { display: flex; flex-wrap: wrap; gap: 10px 16px; align-items: center; font-size: 13px; }
  button { font: inherit; font-weight: 600; padding: 8px 16px; border: 0; border-radius: 8px; background: #1d2330; color: #fff; cursor: pointer; }

  .panel {
    display: none; opacity: 0; transform: translateY(-10px);
    margin-top: 12px; padding: 14px 16px; border-radius: 12px; background: #fff;
    box-shadow: 0 8px 24px rgba(0, 0, 0, .12); font-size: 14px; line-height: 1.5;
    /* display joins the list with allow-discrete, so it waits for the fade */
    transition: opacity .35s, transform .35s, display .35s allow-discrete;
  }
  .panel.open { display: block; opacity: 1; transform: none; }
  /* where the fade-in starts, since display: none had no style to start from */
  @starting-style {
    body:not(.plain) .panel.open { opacity: 0; transform: translateY(-10px); }
  }

  /* the "without" version: same panel, no allow-discrete, no @starting-style */
  .plain .panel { transition: opacity .35s, transform .35s; }

  #log { margin: 12px 0 0; padding: 8px 10px; border-radius: 8px; background: #1d2330; color: #d1fae5;
         font: 12px/1.5 ui-monospace, Consolas, monospace; height: 96px; overflow: auto; }
</style>
</head>
<body>
<div class="bar">
  <button id="toggle" aria-expanded="false" aria-controls="panel">Show details</button>
  <label><input type="checkbox" id="plain"> Turn off allow-discrete and @starting-style</label>
</div>

<div class="panel" id="panel">
  <b>Order #1042</b><br>
  Two items, shipped Tuesday. While closed, this panel is display: none, so it takes no space and cannot be tabbed into.
</div>

<pre id="log"></pre>

<script>
  const panel = document.getElementById('panel');
  const btn = document.getElementById('toggle');
  const log = document.getElementById('log');
  const say = (s) => { log.textContent += s + '\n'; log.scrollTop = log.scrollHeight; };

  btn.addEventListener('click', () => {
    const open = panel.classList.toggle('open');
    btn.textContent = open ? 'Hide details' : 'Show details';
    btn.setAttribute('aria-expanded', open);
  });

  document.getElementById('plain').addEventListener('change', (e) => {
    document.body.classList.toggle('plain', e.target.checked);
    log.textContent = '';
  });

  // one event per property that transitioned
  panel.addEventListener('transitionrun', (e) => say(`run  ${e.propertyName}`));
  panel.addEventListener('transitionend', (e) => {
    say(`end  ${e.propertyName} (${e.elapsedTime}s)`);
    if (e.propertyName === 'opacity' && !panel.classList.contains('open')) say('-> fully closed');
  });
</script>
</body>
</html>
A details panel that fades both ways while staying display: none when closed. The log lists each transition event.

Other discrete properties, such as justify-content, also accept allow-discrete. They switch halfway through the duration. display is the exception that stays visible for the whole fade out and appears at the start of the fade in.

A popover or a dialog is also hidden with display: none, so it uses the same pattern.

Add overlay with allow-discrete to its list as well, so it stays on top while it fades out. More on the values themselves is in CSS display.

The transitionend event

When a transition finishes, the element receives a transitionend event. Use it to clean up after the motion: remove an element, move focus, or start the next step.

panel.addEventListener('transitionend', (e) => {
  if (e.propertyName !== 'opacity') return;  // one of several events
  // the fade has finished
});

Four things to know about it:

  • It fires once per property. A transition on opacity and transform sends two events. A shorthand such as padding sends four, one for each side. Check e.propertyName.

  • e.elapsedTime does not count the delay. A 300ms transition with a 200ms delay reports 0.3.

  • It bubbles. A listener on a parent also hears transitions of its children. Check e.target if that matters.

  • It does not fire if the transition is interrupted. Removing the transition or reversing the value midway sends transitioncancel instead.

transitionrun fires when the transition is created, before any delay. transitionstart fires when the delay is over and the value begins to move.

When it does not work

What you see Cause Fix
The value snaps with no motion No duration, or a time without a unit such as 300 Write 300ms or 0.3s
The whole transition line is ignored A cubic-bezier x value outside 0 to 1 Keep x1 and x2 between 0 and 1
An element you just added appears at its end state It had no previous style, so there was nothing to animate from Use @starting-style
Showing from display: none does not fade in Same cause: display: none leaves no starting style @starting-style plus allow-discrete
Hiding with display: none does not fade out display flips on the first frame Add display .35s allow-discrete to the list
Height from 0 to auto jumps auto is not a number to animate toward See transition height auto
It animates on hover but snaps back The transition is only in the :hover rule Put it on the element's base rule
transitionend never fires The transition was cancelled, or the value never changed Listen for transitioncancel too
transitionend fires several times One event per property, and per side for shorthands Filter on e.propertyName

For motion that should loop, run on page load or pass through several stops, a transition is the wrong tool. Use CSS keyframes instead.

Motion is hard to judge from a screenshot or a written description. The easing, the delay and the fade out only show when someone clicks.

To send a working version, paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its scripts run, so the person you send it to can press the buttons and see the timing themselves. If you adjust a duration later, the same link shows the new version.

Questions people ask

What is the difference between a CSS transition and a CSS animation?

A transition runs only when a value changes, for example when a class is added or the pointer hovers, and it goes from the old value to the new one. An animation made with @keyframes can start on its own when the page loads, loop, and pass through any number of in-between steps.

Why does my transition not run when I show an element that was display: none?

An element with display: none has no starting style, so the browser has nothing to animate from and shows the end state at once. Add an @starting-style rule with the starting values, and add display with allow-discrete to the transition list so it also works on the way out.

Which comes first in the shorthand, duration or delay?

The first time value is always the duration and the second is the delay. transition: opacity 300ms 100ms fades over 300 milliseconds after waiting 100.

Why does transitionend fire four times?

It fires once for every property that finished. A shorthand such as padding or border-radius is transitioned as its separate parts, so you get one event per part. Check event.propertyName and react to just one of them.

Can I transition height: auto?

Not with a plain transition on height, because auto is not a number the browser can count toward. There are working routes, covered in the guide on transitioning height auto.

Keep reading