Transitions and Animations: @keyframes, Easing, @starting-style and prefers-reduced-motion
A dialog opening from display: none has no previous style, so @starting-style must give the transition a starting value, and closing needs allow-discrete on display and overlay. The post measures the difference between transitions and animations, easing including linear(), why transform and opacity are cheap, and reducing motion with prefers-reduced-motion on Bookstore's cart dialog.
Version note: This post follows the CSS Transitions, CSS Animations and CSS Easing Functions Level 2 specifications and the Baseline status of November 2026; the examples were run in Chrome 158 and WebKit 27.2 (Playwright 1.65), and Firefox support is taken from the web-features 3.46 data.
Guess First
In Bookstore, the online bookstore used as the shared example across this site's series, the cart is no longer a separate page but a <dialog> that opens on the catalog. The designer does not want the dialog to pop up abruptly; it should appear softly. The first attempt is these two lines:
dialog { opacity: 0; transition: opacity 200ms linear; }
dialog[open] { opacity: 1; }
A closed dialog has opacity 0, an open one has 1, and there is a 200ms transition between them. What is the dialog's opacity 100ms after showModal() is called: 0.5, or something else?
In this post we move on to the moving parts: the difference between transitions and animations, easing functions and linear(), animating an element that opens from display: none with @starting-style, which properties are cheap to animate, and reducing motion with prefers-reduced-motion instead of switching it off. All measurements are made with Playwright; I paused the browser's animations with the Web Animations API's getAnimations() and moved them to the exact millisecond I wanted, so the outputs are the same on every run.
Transition or Animation?
Both change a property's value over time, but they answer different questions. A transition reacts to a change of state: when a property's value changes for some reason (a class is added, a hover starts), it smooths the move from the old value to the new one. The cascade decides the start and the end; the transition only says how to get there. An animation defines its own values with @keyframes, can start without waiting for a state change, can repeat and can contain intermediate steps. The lab page has two small examples that show the difference:
/* 2) a transition reverses from where it is; an animation starts again */
.chip { opacity: 0.2; transition: opacity 1s linear; }
.chip.on { opacity: 1; }
/* 3) keyframes: delay and fill-mode */
@keyframes bump {
from { scale: 1.5; }
to { scale: 1; }
}
.count { display: inline-block; animation: bump 400ms ease-out 200ms; }
.count.backwards { animation-fill-mode: backwards; }
node scripts/13-motion.mjs
--- chromium
easing 200ms 500ms 800ms
linear 20.0px 50.0px 80.0px
ease 29.5px 80.2px 97.6px
ease-out 30.8px 68.5px 93.8px
overshoot 70.3px 108.7px 104.2px
steps 0.0px 50.0px 75.0px
spring 56.0px 105.3px 100.5px
chip: opacity 0.6 at 500ms, then back to 0.2 in 500ms
during the delay: fill-mode none scale none, backwards scale 1.5
--- webkit: the same values
Look at the last two lines of the output. The on class was added to .chip, and when the 1-second transition was half way (opacity 0.6) the class was removed again. The transition does not start over: it turns back from the current value, and the way back takes 500ms, not 1000ms. The specification calls this the reversing shortening factor; a transition that is interrupted and reversed gets shorter in proportion to how far it had already gone. A user who sweeps the mouse quickly over a button therefore does not see a slow return. An animation has nothing like this; if its class is removed and added again, it starts from the beginning.
The last line shows the effect of animation-fill-mode. The bump animation starts after a 200ms delay. During the delay, the counter with fill-mode: none is not affected at all (scale none) and jumps to 1.5 the moment the animation starts. backwards applies the first keyframe's value during the delay as well: the counter waits at scale 1.5 from the start. In every delayed entrance animation this detail is the difference between a one-frame jump and a clean entrance. In Bookstore the rule is simple: a move from one state to another is a transition; a movement with its own timing (a loading indicator, an attention-grabbing highlight) is an animation.
Easing: cubic-bezier(), steps() and linear()
An easing function turns elapsed time into progress: when half the duration has passed, how far along the way should the value be? In the lab I defined the same 1-second, 100-pixel move with six different functions:
/* 1) the same 1s move with six timing functions */
.track { translate: 0 0; transition: translate 1s var(--easing); }
.moved .track { translate: 100px 0; }
#linear { --easing: linear; }
#ease { --easing: ease; }
#ease-out { --easing: ease-out; }
#overshoot { --easing: cubic-bezier(0.34, 1.56, 0.64, 1); }
#steps { --easing: steps(4, end); }
/* a spring-like curve: linear() joins the points with straight lines */
#spring { --easing: linear(0, 0.7 25%, 1.08 45%, 0.97 65%, 1.01 82%, 1); }
The table in the output shows the positions measured at 200, 500 and 800 milliseconds. linear follows time exactly. The default ease starts very fast and is already at 80% of the way half way through; in the last 500ms it covers only 20 pixels. ease-out slows down more evenly. For an element entering the screen, ease-out usually looks natural, and ease-in for one leaving it: what arrives comes in fast and settles slowly, what leaves starts slowly and speeds away.
cubic-bezier() lets you give the curve's two control points yourself. A control point with a Y value above 1 makes the value overshoot the target and come back: in the overshoot row it is 108.7px at 500ms, 104.2px at 800ms, and 100px at the end. steps(4, end) splits the move into four equal jumps; it is still 0 at 200ms, 50 at 500ms and 75 at 800ms. It is used for discontinuous movement such as sprite animations and a typewriter effect.
Because cubic-bezier() is a single curve, it can pass the target once and come back, but it cannot oscillate several times. For spring-like motion there is linear(): it joins the points you give it with straight lines. linear(0, 0.7 25%, 1.08 45%, 0.97 65%, 1.01 82%, 1) goes 8% past the target at 45% of the duration, falls slightly short at 65% and then settles; the output shows 105.3px at 500ms and 100.5px at 800ms. The more points you give, the smoother the curve; there are tools that generate the points of a real spring motion, and their output can be pasted straight into linear(). linear() became Baseline in December 2023 and has been widely available since June 2026. Both engines computed every value in the table the same way.
A Dialog That Opens from display: none: @starting-style
Back to the guessing question. The lab has three dialogs; the first is the code from the question, the next two fix it step by step:
/* 1) only a transition: there is no "before" style to start from */
dialog { opacity: 0; transition: opacity 200ms linear; }
dialog[open] { opacity: 1; }
/* 2) + @starting-style: the opening fades in */
#starting[open] {
@starting-style { opacity: 0; }
}
/* 3) + allow-discrete on display and overlay: the closing fades out too */
#discrete { transition: opacity 200ms linear, display 200ms allow-discrete, overlay 200ms allow-discrete; }
#discrete[open] {
@starting-style { opacity: 0; }
}
node scripts/13-dialog.mjs
--- chromium
plain
open 0 transitions, opacity 1.00, translate none, display block, backdrop rgba(0, 0, 0, 0.1)
close 0 transitions, opacity 0.00, translate none, display none, backdrop rgba(0, 0, 0, 0)
starting
open 1 transitions, opacity 0.50, translate none, display block, backdrop rgba(0, 0, 0, 0.1)
close 0 transitions, opacity 0.00, translate none, display none, backdrop rgba(0, 0, 0, 0)
discrete
open 1 transitions, opacity 0.50, translate none, display block, backdrop rgba(0, 0, 0, 0.1)
close 3 transitions, opacity 0.50, translate none, display block, backdrop rgba(0, 0, 0, 0.1)
catalog, no-preference
open 3 transitions, opacity 0.68, translate 0px 5.04571px, display block, backdrop rgba(0, 0, 0, 0.34)
close 5 transitions, opacity 0.32, translate 0px 10.9543px, display block, backdrop rgba(0, 0, 0, 0.157)
catalog, reduce
open 2 transitions, opacity 0.68, translate none, display block, backdrop rgba(0, 0, 0, 0.34)
close 4 transitions, opacity 0.32, translate none, display block, backdrop rgba(0, 0, 0, 0.157)
--- webkit
plain
open 0 transitions, opacity 1.00, translate none, display block, backdrop rgba(0, 0, 0, 0.1)
close 0 transitions, opacity 0.00, translate none, display none, backdrop rgba(0, 0, 0, 0.1)
starting
open 1 transitions, opacity 0.50, translate none, display block, backdrop rgba(0, 0, 0, 0.1)
close 0 transitions, opacity 0.00, translate none, display none, backdrop rgba(0, 0, 0, 0.1)
discrete
open 1 transitions, opacity 0.50, translate none, display block, backdrop rgba(0, 0, 0, 0.1)
close 0 transitions, opacity 0.00, translate none, display none, backdrop rgba(0, 0, 0, 0.1)
catalog, no-preference
open 3 transitions, opacity 0.68, translate 0px 5.045708px, display block, backdrop rgba(0, 0, 0, 0.345)
close 0 transitions, opacity 0.00, translate 0px 16px, display none, backdrop rgba(0, 0, 0, 0)
catalog, reduce
open 2 transitions, opacity 0.68, translate none, display block, backdrop rgba(0, 0, 0, 0.345)
close 0 transitions, opacity 0.00, translate none, display none, backdrop rgba(0, 0, 0, 0)
The first dialog (plain) has opacity 1 at 100ms, and the number of running transitions is 0. The transition never started. The reason is that a closed dialog is display: none. The browser starts a transition when an element goes from its previous style to its new one; an element with display: none has no previous style, because it was never rendered. In the first frame after opening, the dialog is drawn straight away with its final value, opacity 1. The same problem exists for popovers, for elements hidden with hidden, and for every element newly added to the DOM.
@starting-style defines that missing "previous style": it tells the browser which value the transition should start from when the element is rendered for the first time (or leaves display: none). In the second dialog (starting) the opacity is 0.50 at 100ms; the opening is now smooth. I wrote @starting-style nested inside the [open] rule; in that case it has no selector of its own and applies to its parent's selector. It has been Baseline since August 2024 (with Firefox 129).
Closing: allow-discrete and overlay
The second dialog opens smoothly but still disappears at once when it closes: on closing, opacity 0 and display none. When it closes, display becomes none immediately, and since the element is no longer drawn there is nothing left to show the opacity transition on. display is a discrete property; there are no values between its values. transition-behavior: allow-discrete (or the word allow-discrete inside the transition shorthand) lets discrete properties take part in a transition as well. Discrete properties normally switch value half way through the duration; display has a separate rule: when going to or from none, the visible value is used for the whole duration of the transition. On opening the dialog is visible from the first frame, and on closing it stays display: block until the transition ends.
A modal dialog has one more property: overlay. Only the browser can change this property, which says whether an element is in the top layer, but it can be added to a transition. If you leave it out, the dialog leaves the top layer as soon as it closes and, during the fade, can appear in the page's normal flow, under other elements. In Chromium the third dialog (discrete) also has opacity 0.50 and display block at 100ms while closing: three transitions are running (opacity, display, overlay).
WebKit's output differs here: on closing the third dialog there is no transition and the dialog disappears at once. According to web-features, transitioning display is available in Chrome 117 and Safari 18 but not in Firefox, and overlay only in Chrome. So the opening animation is Baseline and the closing animation is not. Playwright's WebKit build is not identical to released Safari, so I cannot say for certain from this measurement how Safari behaves here; for the design, though, the conclusion is the same: treat the closing animation as progressive enhancement. In a browser that does not support it, the dialog simply closes without animation, and nothing else breaks.
Bookstore's Cart Dialog
The cart link in the catalog page's header is now a button, and it opens the cart in a dialog:
<button class="cart-link btn" type="button" commandfor="cart-dialog" command="show-modal">Cart <span class="cart-count">1</span></button>
<dialog id="cart-dialog" class="cart-dialog" aria-labelledby="cart-title">
<h2 id="cart-title">Your cart</h2>
<ul class="cart-items">
<li>Dune <span class="price">349.00 TRY</span></li>
</ul>
<div class="cart-actions">
<button class="btn" type="button" commandfor="cart-dialog" command="close">Continue shopping</button>
<a class="btn btn-primary" href="checkout.html">Checkout</a>
</div>
</dialog>
There is no JavaScript at all. The commandfor and command attributes are the invoker commands feature: the button runs the show-modal or close command on the dialog with the given id. It has been Baseline since December 2025; the HTML side was covered in the HTML5 and Semantic HTML series' post on native interactive elements. The dialog's styles live in a new component file, imported into the components layer before preferences.css:
/* the cart dialog sits in the top layer; it fades in from display: none and fades out again */
.cart-dialog {
inline-size: min(28rem, 100% - 2 * var(--space));
padding: var(--space);
border: 1px solid var(--color-border);
border-radius: var(--radius);
background-color: var(--color-surface);
color: var(--color-text);
opacity: 0;
/* display and overlay are discrete: allow-discrete keeps the dialog shown and in the top layer until the fade ends */
transition:
opacity 200ms ease-out,
translate 200ms ease-out,
display 200ms allow-discrete,
overlay 200ms allow-discrete;
&[open] {
opacity: 1;
/* the values for the first frame after display: none, so there is something to transition from */
@starting-style {
opacity: 0;
}
}
&::backdrop {
background-color: rgb(0 0 0 / 0%);
transition:
background-color 200ms ease-out,
display 200ms allow-discrete,
overlay 200ms allow-discrete;
}
&[open]::backdrop {
background-color: rgb(0 0 0 / 50%);
@starting-style {
background-color: rgb(0 0 0 / 0%);
}
}
}
/* the slide is extra motion: only for users who did not ask for less; everyone keeps the fade */
@media (prefers-reduced-motion: no-preference) {
.cart-dialog {
translate: 0 1rem;
&[open] {
translate: 0 0;
@starting-style {
translate: 0 1rem;
}
}
}
}
.cart-items {
padding: 0;
list-style: none;
& li {
display: flex;
justify-content: space-between;
}
}
.cart-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
All three steps from the lab are here: @starting-style inside [open], and allow-discrete for display and overlay in the transition list. ::backdrop has its own transition too; the page behind darkens together with the dialog. In the script's "catalog, no-preference" lines, at 100 milliseconds into the opening the opacity is 0.68, the dialog is 5 pixels along its 1rem path, and the backdrop is half way; on closing, five transitions run together in Chromium. The opacity is 0.68 rather than 0.50 because I used ease-out here.
I put the slide in a separate media query, and I wrote it in the component's own file rather than in preferences.css. preferences.css is for page-wide preferences (smooth scrolling, contrast tokens); which of a dialog's movements are unnecessary is the component's decision, and that knowledge should travel with the component. More on this decision a few sections below.
Which Properties Are Cheap to Animate: transform, opacity and will-change
Animating every property does not cost the same. If a property affects layout (width, margin, top), the browser recalculates styles, redoes layout and repaints the page on every frame. transform and opacity do not affect layout; the browser can put the element on its own layer and change only that layer's position or opacity on each frame, and this work is mostly done on the compositor without touching the main thread. In the lab I wrote the same movement once with margin-inline-start and once with transform, and read the amount of work done over 1 second from Chromium's DevTools protocol:
/* the same movement, once with a layout property and once with transform */
@keyframes slide-margin { to { margin-inline-start: 300px; } }
@keyframes slide-transform { to { transform: translateX(300px); } }
body.margin .box { animation: slide-margin 1s linear infinite alternate; }
body.transform .box { animation: slide-transform 1s linear infinite alternate; }
/* will-change promotes every card to its own layer, even when nothing moves */
.card { inline-size: 100px; block-size: 20px; margin: 2px; background-color: rgb(229, 231, 235); }
body.promoted .card { will-change: transform; }
node scripts/13-cost.mjs
margin 1s: layouts 59, style recalcs 60
transform 1s: layouts 1, style recalcs 2
will-change off : 4 layers
will-change on 50 cards: 54 layers
With margin there are 59 layouts and 60 style recalculations in 1 second; the screen draws 60 frames per second, so that is one per frame. With transform there is 1 layout and 2 style recalculations in the same time; once the animation has started, the main thread hardly works at all. This means a transform animation can keep running smoothly even while the main thread is busy with JavaScript. That is why Bookstore's dialog uses translate and opacity; translate is an individually written part of transform and likewise does not affect layout. The details of the rendering pipeline and their effect on Core Web Vitals are in the upcoming Web Performance series.
will-change: transform tells the browser "this element's transform is going to change, be prepared" and moves the element to its own layer in advance. The last two lines show the cost: giving will-change to 50 cards raised the number of layers on the page from 4 to 54, although nothing moves. Every layer is a separate surface in GPU memory; on phones that memory fills up quickly. Give will-change to the few elements that are about to move, ideally just before the movement, and remove it when the work is done. Writing it permanently in CSS for every card in a list creates a bigger problem than the one you are trying to solve. This measurement is Chromium-only, because WebKit offers no such protocol through Playwright.
prefers-reduced-motion: Reduce, Do Not Remove
Operating systems have a "reduce motion" setting, and the browser reports it as prefers-reduced-motion: reduce. Some of the people who use this setting get dizzy or nauseous from large movements on screen. The setting is called "reduce", not "turn off". The dialog sliding into place means part of the screen moves; the dialog fading in where it is describes a change of state, and is harmless and useful feedback for these users as well. That is why in Bookstore the slide is inside prefers-reduced-motion: no-preference, while the fade is for everyone.
The "catalog, reduce" lines in the script come from a page opened with Playwright's reducedMotion: 'reduce' option: translate is none, but the opacity is still 0.68 and there is one running transition fewer. Writing the rule inside no-preference, that is, adding motion for users whose preference is known, is safer than the reverse: if you add a new movement and forget to update a reduce block, the movement stays off by default. The accessibility reasoning behind this preference is in the upcoming Accessibility (a11y) series.
Production Notes
The features in this post have different Baseline statuses. Transitions, animations and cubic-bezier() have been everywhere for years. linear() is widely available. @starting-style and transition-behavior have been Baseline since August 2024, invoker commands since December 2025. Transitioning display and overlay are not Baseline. Bookstore's cart dialog therefore works in three tiers: it opens and closes in every browser, opens smoothly in Baseline browsers, and also closes smoothly in Chromium.
When you test an animated element, do not rely on timing. A test that uses Playwright's waitForTimeout to "wait 200ms, then measure" can read an intermediate value on a slow CI machine. Pausing animations with getAnimations() and moving them to a specific moment, as the scripts in this post do, or awaiting the animation.finished promise gives the same result on every run. Running end-to-end tests with reducedMotion: 'reduce' also shortens waiting, but then you need to test the animated variant separately.
Transition durations are a design decision too, but there is a limit: if a change the user is waiting for takes longer than 200–300ms, the interface feels slow. Use short durations for frequently opened elements such as dialogs and menus; leave long, eye-catching movements for places seen once, such as a first visit.
Common Pitfalls
First, writing only a transition for an element that opens from display: none and expecting it to work. Without a starting value from @starting-style, the transition never starts.
Second, transition: all. Written without thinking about which property changes, all also animates properties that trigger layout, and makes every colour on the page change slowly when the theme switches. List the properties that take part in the transition one by one.
Third, copying a ready-made rule that gives the whole page animation-duration: 0.01ms !important for prefers-reduced-motion: reduce. It removes the informative fades too, and because of !important it reverses the layer order, as we saw in the first post of the series. Make the reduction decision component by component.
Fourth, treating will-change as a performance optimisation and writing it everywhere. As we measured, each one means a layer and memory.
A Small Experiment
★☆☆ Add two more points to the #spring curve in the lab (for example 0.995 90%), guess how the value at 800ms changes in the output of node scripts/13-motion.mjs, then check.
★★☆ The .stock-note in book.html is currently hidden with hidden. Make it fade in within 300ms when it becomes visible, using @starting-style, and measure its opacity at 150 milliseconds with a script. Keep in mind the !important rule for the hidden attribute in the reset.
★★★ Turn the cart dialog into a non-modal popover (the popover attribute and command="toggle-popover") and write the same entry and exit animation. Extend 13-dialog.mjs for the popover and measure the difference between Chromium and WebKit again.
In the next post we move to animations tied to scrolling rather than time: animation-timeline with scroll() and view(), a reading progress bar and book cards that appear as they enter the screen; then view transitions for page and list changes, withViewTransitions() in the Angular router, and cross-document view transitions in multi-page applications such as ASP.NET Core MVC.