Chapter 13 — CSS Variables, Transitions and Animations
Section 1 — What a CSS variable is
A CSS variable, more formally called a custom property, is a named value that you define once in a stylesheet and reuse wherever that value is needed. Instead of writing the same colour, spacing, or font size in dozens of rules, you give it a name — for example, a brand colour called --brand — and then refer to that name each time. If the brand colour later changes, you change it in one place, and every rule that uses it updates automatically. That single change propagating everywhere is the core benefit, and it transforms a stylesheet from a collection of repeated literals into a small system with named decisions.
Custom properties always begin with two hyphens, which distinguishes them from ordinary CSS properties. You might write --brand: #0066cc; or --spacing: 1rem; or --radius: 8px;. The name after the hyphens is yours to choose, and good names describe purpose rather than appearance: --brand is better than --blue, because the brand colour might one day become green, while --blue would then be misleading. Similarly, --surface for a card background or --text-muted for secondary text tells a future reader what the value is for, not merely what it looks like today.
A variable on its own does nothing until it is used. The var() function retrieves it, as in color: var(--brand);. That function can appear anywhere a value is expected: in colours, lengths, font families, shadows, and even inside other functions. You can also combine a variable with other values, for example padding: var(--spacing) calc(var(--spacing) * 2);, although arithmetic requires the calc() function, which you may already know. The point is that variables are not limited to colours; any repeated value — a shadow, a border radius, a maximum width, a transition duration — can become a named property.
Variables also differ from ordinary values in that they participate in the cascade and inheritance, which later sections explore in detail. A variable defined on a parent is available to its children, a variable redefined on a child overrides the parent's value for that subtree, and a variable can be changed by a class, a media query, or JavaScript. That dynamism is what separates native CSS variables from older preprocessor variables, which were replaced at build time and could not change in the browser. Native variables are live: change the value, and the page responds.
Finally, variables encourage a design habit worth forming early. When you start a project, spend a few minutes naming its core decisions — colours, spacing scale, radii, font stacks, durations — as variables at the top of the stylesheet. That inventory becomes a shared language for the project, makes consistent design easier, and makes later changes safer, because a change to a named decision is deliberate and visible, rather than a hunt through dozens of scattered literals.
Check: In plain words, what problem does a CSS variable solve?
Section 2 — Declaring variables in :root
The most common place to declare CSS variables is the :root pseudo-class, which matches the document's root element — the <html> element in an HTML page. Declaring variables there makes them global, in the sense that every element on the page can use them, because every element is a descendant of the root. A typical declaration block looks like this:
:root {
--brand: #0066cc;
--text: #222222;
--spacing: 1rem;
--radius: 8px;
}
Why :root rather than html or body? In practice, :root and html select the same element in an HTML document, but :root has higher specificity, which makes its declarations slightly harder to override accidentally, and it is the conventional place readers expect to find global variables. Declaring on body would also work for most elements, but elements outside the body, such as the head, are irrelevant, and the convention of :root signals intent: these are global design decisions, not styles for a particular element.
A global block should be curated, not exhaustive. It is tempting to turn every value in a stylesheet into a variable, but that creates a different kind of clutter: a long list of names that must be remembered, many used only once. A variable earns its place when a value is repeated, when it represents a design decision that might change, or when it needs to change as a group — for example, all colours in a theme switch. A one-off value, such as the padding of a single unusual component, is often clearer left as a literal in that component's rule, where its context is obvious.
Organisation within :root helps too. Group related variables with comments — colours together, spacing together, typography together — so a reader can scan the block and understand the design system at a glance. Keep the naming consistent: if colours are --brand, --brand-dark, and --brand-light, do not also have --dark-blue elsewhere. Consistency in naming is what makes a variable system feel like a system rather than a pile of names, and it is what lets a new developer, or future you, find and change a value confidently.
Finally, remember that :root declarations are still ordinary CSS: they can be overridden by later rules, by media queries, and by classes, as later sections show. Declaring a variable in :root does not freeze it; it establishes a default that the rest of the stylesheet, and the page's interactions, can adjust. That combination — a clear global default, plus local overrides — is the foundation of everything variables make possible.
A naming convention worth adopting is to keep variable names short but descriptive, using hyphens between words, and to avoid names that merely repeat the value, such as --blue-600 for a colour that may later change. A name should survive a value change without becoming misleading, because renaming a variable used in many places is itself a chore. When in doubt, name the role the value plays, and let the value itself live only in the declaration.
Check: Why is :root a good place to declare global variables?
Section 3 — Using var() and providing a fallback
Once a variable is declared, you use it with the var() function, and that function has a second, optional argument that is easy to overlook but important to understand: a fallback value. The fallback is used when the variable is not defined, or when its value is invalid for the property in question. The syntax is var(--name, fallback), for example color: var(--brand, #0066cc);. If --brand is defined, its value is used; if it is not, the browser uses the fallback colour instead of leaving the property unset.
Fallbacks serve several purposes. First, they make a component more robust when reused in a context where a variable might not be defined. A card component that uses var(--card-bg, white) will still look sensible on a page that never defined --card-bg, because it falls back to white. That robustness matters in larger projects, where components move between pages, or in a design system used by several sites with different variable sets. Second, fallbacks document intent: a reader can see what the expected default is without hunting for the variable's declaration.
Fallbacks can also be nested, which is useful when one variable depends on another: color: var(--heading-color, var(--text, black));. Here the browser tries --heading-color first, then --text, then black. That chain expresses a sensible hierarchy — a specific value, then a general value, then a hard default — and it keeps the component working even if only some variables are defined. Nesting should be used sparingly, however, because a long chain becomes hard to follow, and a simpler structure is usually clearer.
There is a subtle behaviour worth knowing: if a variable is defined but its value is invalid for the property — for example, --brand: hello; used as a colour — the property does not fall back to the var() fallback. Instead, the property becomes invalid at computed-value time, and the element inherits or uses its initial value. In other words, the fallback only rescues an undefined variable, not a badly defined one. That distinction surprises beginners, and it is a reason to keep variable values valid and to test them, rather than relying on fallbacks to catch typos.
A practical habit is to provide fallbacks for variables that a component genuinely cannot function without, and to omit them where the variable is guaranteed by the project's :root block. Too many fallbacks add noise; too few make a component fragile outside its home page. Judgement, as always, is about context: a shared component gets fallbacks, a page-specific rule can rely on the global block.
Fallbacks are also helpful when a stylesheet is loaded in an older browser that does not support custom properties at all, although in that case the entire var() declaration is ignored rather than falling back, so a separate fallback declaration before the var() line is needed for true legacy support. That double-declaration pattern is rarely needed today, but recognising it in older code prevents confusion when you see the same property written twice in succession.
Check: When does a var() fallback actually get used?
Section 4 — Variables and inheritance
CSS variables follow the same inheritance rules as other CSS properties, and understanding those rules is what makes variables powerful rather than merely convenient. When you define a variable on an element, its descendants inherit that value unless they define their own. That means a variable set on a container flows down through its children, grandchildren, and so on, until something overrides it. The variable is not copied into each element at declaration time; it is resolved when it is used, following the element's ancestry at that moment.
A concrete example makes this clear. Suppose you define --accent: teal; on a page section, and inside that section there is a card with a button that uses background: var(--accent);. The button will be teal, because it inherits --accent from the section through the card. If a second section defines --accent: orange;, buttons inside that section will be orange, even though they share the same button rule. One rule, two results, determined by context. That is inheritance doing useful work: the component stays generic, and its surroundings supply the specific value.
Inheritance also explains why a variable can be changed by a class on an ancestor. Adding a class such as .dark to a container, with --text: white; and --surface: #222; inside it, redefines those variables for everything inside the container, and every component that uses them updates at once. That is the mechanism behind theme switching, which a later section explores in full. The key insight is that the theme does not restyle each component individually; it changes the variables the components already use, and the components follow.
There is a related concept worth distinguishing: a variable's value is inherited as written, not as computed for a particular property. If a variable holds 1em, and it is inherited into an element with a different font size, the em will be resolved against that element's font size when the variable is used, not against the ancestor's. That live resolution can surprise you if you expect a variable to freeze a value, but it is also what makes variables flexible: the same variable can mean slightly different absolute sizes in different contexts, while keeping the same relative meaning.
Finally, inheritance means that a variable defined on an element does not leak sideways or upwards. A variable on one card does not affect its sibling card, and a variable on a child does not affect its parent. That scoping is a feature: it lets you customise one subtree without worrying about the rest of the page, and it is the foundation of the component-level variables in the next section.
Inheritance also means that a variable's value can be inspected in developer tools by selecting an element and looking at its computed custom properties, which shows the resolved value after inheritance and overrides. That inspection is invaluable when a variable seems to have the wrong value, because it reveals whether the problem is the declaration, an unexpected override on an ancestor, or a missing definition altogether.
Check: If a variable is defined on a section, which elements can use it?
Section 5 — Local variables for a component
Global variables are useful, but many variables belong to a single component rather than to the whole page. A card, for example, might have its own padding, its own image ratio, or its own accent colour that differs from the page's brand. Defining those values as local variables on the component itself keeps them close to where they are used, makes the component self-contained, and avoids polluting the global :root block with names that only one component needs.
A typical pattern looks like this:
.card {
--card-padding: 1rem;
--card-gap: 0.5rem;
padding: var(--card-padding);
display: grid;
gap: var(--card-gap);
}
.card--wide {
--card-padding: 2rem;
}
Here, --card-padding and --card-gap are defined on .card, so every card uses them, and any descendant of a card — a title, a paragraph, a button inside the card — can also use them, because variables inherit downwards. The variant .card--wide does not restyle the card's padding directly; it redefines the variable, and the existing padding: var(--card-padding) rule picks up the new value. That indirection is elegant: the variant changes a decision, not a declaration, and if the padding is used in several places inside the card, all of them update together.
Local variables also make components easier to customise from outside without editing the component's stylesheet. A page that needs a special card can set --card-padding on a wrapper element, and the card inside will inherit the new value, because inheritance flows from the wrapper into the card. That is a form of theming at a small scale: the component exposes a few named knobs, and its users turn those knobs by setting variables, rather than by overriding many individual properties. Design systems use this pattern extensively, documenting a component's custom properties as its public interface.
Naming matters for local variables too. Prefixing with the component name — --card-, --button-, --nav- — avoids collisions with other components' variables and makes the variable's home obvious. Without a prefix, a generic name like --padding on one component could be confused with, or accidentally overridden by, a similar name elsewhere. A clear prefix is cheap insurance, and it signals to a reader that the variable is local, not global.
The balance to strike is between global and local: global variables for design-wide decisions such as brand colours and spacing scale, local variables for component-specific decisions such as a card's internal gap. When that balance is right, a stylesheet reads as a hierarchy of decisions, from the page down to the component, each level supplying what the next level needs.
Local variables also simplify testing a component in isolation, because the component carries its own decisions with it when it is moved to a test page or a style guide. A card that relies only on global variables may look different, or broken, outside its home page, while a card with local defaults looks the same wherever it is placed, and only changes when a surrounding context deliberately overrides one of its knobs.
Check: Why define a variable on a component instead of in :root?
Section 6 — Theming with a class switch
Theming — offering a light and a dark version of a site, or a brand variant for a special section — is the showcase use of CSS variables, because it demonstrates their live, cascading nature better than any other example. The technique is simple in outline: define a set of semantic variables for the default theme, then redefine the same variables under a theme class, and let every component follow the new values automatically.
A minimal example starts with a light theme in :root:
:root {
--surface: white;
--text: #222222;
}
body {
background: var(--surface);
color: var(--text);
}
Then a dark theme redefines the same names:
body.dark {
--surface: #1a1a1a;
--text: #f0f0f0;
}
Adding the dark class to the body — for example, when a visitor clicks a theme toggle button — changes the variables for the whole page, and every element that uses --surface or --text updates instantly, without any JavaScript restyling individual elements. The JavaScript only toggles a class; CSS does the rest. That separation is clean and maintainable: the theme is a set of variable values, not a second stylesheet duplicating every component's rules.
Semantic naming is what makes this work well. Variables should be named for their role — --surface, --text, --border, --accent — not for their light-theme appearance, such as --white or --black. A variable called --white that holds a dark grey in the dark theme is confusing, while --surface holds white in one theme and dark grey in another without any contradiction. Good theme variables describe where a colour is used, so their values can change freely while their meaning stays stable.
Theming can also be scoped to a section rather than the whole page. Applying a theme class to a single container — a dark callout box on a light page, for instance — redefines the variables only inside that container, thanks to inheritance. The callout's text, borders, and accents switch to the dark values, while the rest of the page stays light. That scoping is a direct consequence of variables inheriting downwards, and it is difficult to achieve cleanly with repeated literals, which would require restyling each element inside the callout individually.
Finally, a theme switch should be more than a colour swap. Consider contrast, so text remains readable in both themes; consider images and shadows, which may need adjusting; and consider persisting the visitor's choice, so it survives a page reload. Those details belong to the product, but the mechanism — variables plus a class — remains the same, and it scales from a two-theme toggle to a whole design system with multiple brand variants.
Theming with a class also works well with transitions, as a later section shows, because changing variables is a change in the properties that use them, and transitions smooth those changes. That interplay — variables for values, transitions for change — is a recurring pattern in this chapter, and it is worth noticing here as an early example of how the chapter's topics combine rather than standing alone.
Check: What does JavaScript actually change when a theme toggle is clicked?
Section 7 — Variables versus preprocessor variables
Before native CSS variables existed, developers used preprocessor variables, such as those in Sass, to achieve a similar goal: define a value once, reuse it by name. It is worth understanding the difference, because the two look alike in code but behave very differently in the browser, and choosing between them — or using both — depends on what you need.
A preprocessor variable, written in Sass as $brand: #0066cc;, is processed before the page ever reaches a browser. The preprocessor replaces every use of $brand with the literal colour, and the browser receives ordinary CSS with no variables in it at all. That means a preprocessor variable cannot change after the page loads: it cannot respond to a theme toggle, a media query in the browser, or a JavaScript interaction, because by the time the browser sees the code, the variable is gone. It is a build-time convenience, not a runtime feature.
A native CSS variable, by contrast, survives into the browser. The browser knows the variable, its value, and its scope, and it can re-evaluate uses of it whenever the value changes — when a class is added, when a media query matches, or when JavaScript sets a new value. That liveness is the decisive difference. Theming, responsive variable changes, and interactive adjustments all require native variables, because they all involve changing a value after the page has loaded.
Preprocessors still have uses that native variables do not cover. A preprocessor can compute values at build time, generate repetitive rules with loops, and perform operations that CSS alone cannot, such as manipulating colours in complex ways before delivery. Many projects therefore use both: preprocessor variables and functions for build-time generation, and native variables for runtime theming and interaction. There is no conflict in that combination, provided you keep clear which layer each variable belongs to, and do not confuse a build-time name with a runtime one.
For a beginner, the practical advice is to learn native variables first, because they are part of CSS itself, work without any build tool, and teach the cascade and inheritance directly. If you later adopt a preprocessor, you will understand exactly what it adds — build-time power — and what it cannot do — runtime change. That understanding prevents the common confusion of expecting a Sass variable to respond to a class toggle, which it never will, because it ceased to exist before the browser loaded the page.
Another difference is tooling: a preprocessor variable can be used in contexts where CSS variables cannot, such as in a media query condition or in a selector, because the preprocessor generates the final CSS text before the browser sees it. Native variables, being runtime values, cannot change a selector or a media query, which are structural parts of the stylesheet. Knowing that boundary prevents attempts that cannot work, such as trying to set a breakpoint from a variable.
Check: Why can a native CSS variable change a theme at runtime, but a Sass variable cannot?
Section 8 — Variables for spacing and layout
Variables are often introduced through colours, but they are equally valuable for spacing, sizing, and layout, where consistency matters just as much and where a single change should ripple through a design. A spacing scale — a small set of named sizes used throughout a page — is a classic example. Instead of scattering 8px, 12px, 16px, and 24px through a stylesheet with no system, you define a scale and use it everywhere:
:root {
--space-sm: 0.5rem;
--space-md: 1rem;
--space-lg: 2rem;
}
Now a card's padding, a section's margin, and a grid's gap can all draw from the same scale, and the design feels coherent because its spacing is drawn from a limited, intentional set rather than from arbitrary numbers. If the design later needs more breathing room, increasing --space-md from 1rem to 1.25rem adjusts every component that uses it, in proportion, without hunting for individual values. That kind of global rhythm adjustment is difficult with literals and trivial with variables.
Variables also help with layout decisions that change at breakpoints. A container's maximum width, a sidebar's width, or a grid's minimum column size can be a variable that a media query redefines. For example, --content-max: 700px; on small screens might become 1100px on wide screens, with the container rule itself unchanged: max-width: var(--content-max);. The media query changes a decision, not a layout rule, which keeps the stylesheet's structure clear and makes the breakpoint's purpose obvious: at this width, the content is allowed to be wider.
Another layout use is component sizing. A button component might define --button-padding and use it for both its padding and the spacing of an icon inside it, so the two stay in proportion when a size variant changes the variable. A navigation bar might define --nav-height and use it both for the bar's height and for the top padding of the content below it, so the content never slides under the bar when the height changes. In each case, the variable captures a relationship — these two values must stay in sync — and the stylesheet enforces that relationship automatically.
The discipline is the same as for colours: name variables by purpose, keep the scale small, and use variables where a value is repeated or must change as a group. Spacing and layout variables, used well, turn a stylesheet from a list of measurements into a system of proportions, and that system is what makes a design feel intentional rather than accidental.
Spacing variables also make responsive adjustments cleaner, because a media query can redefine a spacing variable once, and every component using it gains the new spacing, rather than each component needing its own media query for padding. That central adjustment is especially useful for a site-wide rhythm change at a breakpoint, where a single variable change produces a coherent shift across the whole page.
Check: How does a spacing scale make a design feel more coherent?
Section 9 — Updating variables with JavaScript
Because CSS variables are live in the browser, JavaScript can read and change them, which opens up interactive designs that would be cumbersome with ordinary CSS alone. The two key methods are getComputedStyle() for reading and setProperty() for writing, and both operate on an element's style, usually the root element for global variables.
Reading a variable looks like this:
const root = document.documentElement;
const brand = getComputedStyle(root).getPropertyValue('--brand');
That returns the variable's current value as a string, including any whitespace, so trimming is often wise. Writing a variable is similarly direct:
root.style.setProperty('--brand', '#cc3300');
The moment that line runs, every element using var(--brand) updates, just as if a theme class had been added. No element needs to be restyled individually, and no stylesheet needs to be reloaded. That is the same mechanism as theming, driven by code rather than by a class, and it suits cases where the new value is computed — for example, a colour picked from a colour input, a spacing value from a slider, or a position from a mouse movement.
A practical example is a customisation panel: a visitor drags a slider labelled "spacing," and JavaScript sets --spacing to the slider's value, so the whole page's spacing adjusts live. Another is a theme picker that offers more than two themes: instead of defining a class for each theme, JavaScript sets several variables at once from a theme object. Both examples share the same pattern: user input produces values, values become variables, and CSS renders the result. The separation keeps the code clean: JavaScript handles input and values, CSS handles presentation.
There are cautions. Setting a variable on an element's inline style overrides stylesheet values for that element and its descendants, which can surprise you if you later expect a stylesheet rule to take effect. Inline styles have high specificity, so a variable set by JavaScript will beat a stylesheet value unless the stylesheet uses !important, which should be avoided. It is also wise to validate values before setting them — a colour picker gives valid colours, but a free text input might not — because an invalid variable value can make properties that use it fall back to their initial values, as discussed earlier.
Used thoughtfully, JavaScript-driven variables bridge the gap between interaction and design, letting visitors shape a page's appearance in ways that feel immediate and responsive, without the developer having to anticipate and pre-write every possible appearance.
JavaScript can also remove a variable it previously set, using removeProperty, which restores the stylesheet value for that element and its descendants. That removal is useful for a reset button or for clearing a customisation, because it returns control to the stylesheet rather than leaving an inline value that would continue to override future stylesheet changes. Pairing setProperty with removeProperty gives a complete, reversible way to manage variables from code.
Check: Which JavaScript method changes a CSS variable's value?
Section 10 — What a transition is
A transition is a way of making a change in CSS happen gradually over time, instead of instantly. Without a transition, a button that changes colour on hover jumps from one colour to another in a single frame: the old colour is there, then the new colour is there, with nothing in between. With a transition, the browser interpolates between the two values over a duration you choose — half a second, for example — so the colour glides from old to new. That glide is what makes an interface feel smooth, polished, and alive, rather than abrupt and mechanical.
Transitions are triggered by a change in a property's value, whatever causes that change. Hovering over an element, focusing it with a keyboard, adding or removing a class with JavaScript, or a media query matching at a new width can all change a value, and if a transition is defined for that property, the change will animate. The transition itself does not cause the change; it only smooths a change that something else causes. That distinction is important: you do not "run" a transition in the way you run an animation; you declare that, when this property changes, it should change gradually.
A basic example shows the pattern. A button has a background colour, and on hover it has a different one, with a transition declared on the base rule:
.button {
background: #0066cc;
transition: background 0.3s;
}
.button:hover {
background: #004499;
}
Note that the transition is declared on the base .button rule, not on the :hover rule. That placement matters: the transition applies to changes in both directions, so the colour glides when the mouse enters and glides back when it leaves. If the transition were only on the hover rule, the entry would animate but the exit would snap instantly, because the transition would no longer apply once the hover ended. Declaring transitions on the base rule is therefore a habit to form early.
Transitions are best used for small, meaningful changes: a colour shift, a slight lift, a fade, a menu opening. They are not a general animation system — that is what keyframe animations, covered later in this chapter, are for — but for the everyday feedback of an interface, they are the right tool, and they are simple enough to use everywhere a change should feel considered rather than sudden.
Transitions also apply to changes caused by media queries, which means a layout that changes at a breakpoint can animate between its two arrangements if the changing properties are transitionable. That can be pleasant for a small change, such as a colour, but it can also be distracting for a large layout shift, so breakpoint transitions should be used sparingly and tested by resizing, to ensure they help rather than hinder the visitor's orientation.
Check: Does a transition cause a change, or smooth a change caused by something else?
Section 11 — transition-property and transition-duration
Every transition is built from at least two pieces of information: which property should animate, and how long the animation should take. Those are the transition-property and transition-duration properties, and understanding them separately, before learning the shorthand that combines them, makes transitions far less mysterious when something goes wrong.
transition-property names the CSS property whose changes should be gradual. It can name a single property, such as background-color, or several properties separated by commas, such as background-color, transform. It can also use the keyword all, meaning every animatable property on the element should transition when it changes. The keyword all is convenient, but it is also a blunt instrument: it will animate properties you did not intend to animate, including ones that change for unrelated reasons, and it can make debugging harder because the source of an animation is less obvious. Naming the specific properties you intend is clearer and safer, especially as a stylesheet grows.
transition-duration states how long the change takes, in seconds or milliseconds. 0.3s and 300ms mean the same thing; seconds are common for longer animations, milliseconds for short interface feedback. The duration is a design decision, not a technical one: too short, and the change still feels abrupt; too long, and the interface feels sluggish, as if it is lagging behind the visitor's actions. For most hover and focus feedback, durations between 150 and 300 milliseconds feel responsive without being jarring, while larger movements, such as a panel opening, may warrant 300 to 500 milliseconds. Those ranges are guidelines from common practice, not rules, and testing with real users — or simply with your own hand on a mouse — is the best judge.
An important detail is that duration applies per property when several are listed. If you write transition-property: background-color, transform; transition-duration: 0.3s, 0.5s;, the background animates over 0.3 seconds and the transform over 0.5 seconds, matched by position in the lists. If the lists are of unequal length, the shorter list repeats to match the longer one, which can produce surprising pairings if you are not careful. Keeping the lists aligned, or using the shorthand where each transition is written as a complete unit, avoids that confusion.
Another detail is that a transition only applies if the property's value actually changes to a different, interpolable value. Changing a colour from blue to dark blue interpolates smoothly, because colours have intermediate values. Changing a property from one keyword to an unrelated keyword with no intermediate — for example, from display: none to display: block — has no intermediate values, so it cannot transition, a point a later section returns to. Understanding which properties can interpolate is part of choosing transitions that will actually work.
Finally, remember that duration is not the whole timing story. The next sections cover timing functions, which shape how the change progresses within its duration, and delay, which postpones its start. Together, property, duration, timing, and delay give you complete control over a transition's behaviour, and each deserves to be understood on its own before they are combined.
Check: What do transition-property and transition-duration each control?
Section 12 — Timing functions: ease, linear and more
A transition's duration says how long a change takes, but its timing function says how the change progresses within that time: whether it starts fast and slows down, starts slow and speeds up, or moves at a constant rate. That shape matters enormously to how an animation feels, because human perception is sensitive to acceleration and deceleration, and a change that eases into place feels natural in a way that a mechanically constant change does not.
The most common timing function is ease, which is also the default if you do not specify one. It starts relatively slowly, accelerates through the middle, and slows down again as it approaches its end. That gentle landing is why ease feels polished for most interface changes: the element does not slam into its final state, but settles into it. ease-in starts slowly and accelerates to the end, which can feel like something gathering momentum, but its abrupt finish can feel unfinished. ease-out starts quickly and decelerates, which feels responsive at the start and gentle at the end, making it a good choice for elements appearing or moving into view. ease-in-out combines both: slow start, fast middle, slow end, which suits movements that travel a distance and should not jar at either end.
linear is the exception that proves the rule: it moves at a constant rate from start to finish, with no acceleration at all. That constancy feels mechanical, which is rarely what an interface wants, but it is exactly right for things that should feel mechanical or continuous, such as a progress bar filling, a spinner rotating, or a colour cycling evenly. Using linear for a button hover, by contrast, can feel flat, because the change neither responds quickly nor settles gently.
Beyond the keywords, CSS offers cubic-bezier(), which lets you define a custom acceleration curve with four numbers, and steps(), which moves in discrete jumps rather than smoothly — both covered in later sections. For now, the practical advice is to start with ease or ease-out for most interface transitions, reserve linear for continuous or mechanical motion, and notice the difference by trying the same transition with different timing functions side by side. The numbers are identical; only the curve changes, yet the feel changes completely.
Timing functions also interact with duration. A long duration with ease-in can feel sluggish at the start, because the slow beginning lasts longer, while a short duration makes the choice of curve less noticeable. As a rule, the shorter the duration, the less the curve matters, and the longer the movement, the more carefully the curve should be chosen. Experimenting with duration and curve together, rather than treating them as independent settings, is how you develop a feel for motion design.
Check: Which timing function starts quickly and slows down at the end?
Section 13 — Transition delay
Transition delay postpones the start of a transition after the property's value has changed. If a button's colour changes on hover and its transition has a delay of 0.2 seconds, the colour stays at its old value for 0.2 seconds after the mouse arrives, and only then begins its gradual change. Delay is declared with transition-delay, in seconds or milliseconds, just like duration, and it can be specified per property alongside duration in the shorthand.
Delay has legitimate uses, and the most common is staggering: making several elements animate in sequence rather than all at once. A menu whose items each have a slightly longer delay than the last — the first item 0s, the second 0.05s, the third 0.1s — will appear to unfold item by item, which feels more deliberate and easier to follow than all items appearing simultaneously. A card whose image fades first and whose text follows a fraction later can guide the eye in a similar way. In those cases, delay is not waiting for its own sake; it is choreography, ordering the parts of a change so the visitor can follow them.
Delay also has a use in preventing accidental animations. A navigation dropdown that appears instantly on hover can flicker when the mouse briefly passes over its trigger on the way elsewhere. A short delay — perhaps 0.1 seconds — means the dropdown only appears if the mouse lingers, which reduces flicker. That technique must be used cautiously, however, because a delay that is too long makes the interface feel unresponsive, as if it is ignoring the visitor. The visitor's action should usually produce a visible response quickly, even if the full animation takes longer to complete.
A crucial detail is that delay applies in both directions unless you specify otherwise. If a hover transition has a delay, the return transition when the mouse leaves will also be delayed, which can feel like the element is stuck in its hover state. To avoid that, you can declare a delay on the base rule for the entry and a different delay — often zero — on the hover rule for the exit, or vice versa. Because the transition properties on the element at the moment of change govern that change, you can tune entry and exit separately, which is a useful technique for menus and tooltips.
Finally, delay should never be used to hide slowness or to make a visitor wait for information they need. A delay that postpones a critical message, an error, or a confirmation is not choreography; it is friction. Reserve delay for ordering and for preventing flicker, keep it short, and always ask whether the visitor benefits from waiting, or merely tolerates it.
Check: What does a transition delay postpone, and when is it useful?
Section 14 — The transition shorthand
Writing transition-property, transition-duration, transition-timing-function, and transition-delay as four separate declarations works, but it is verbose, and it becomes error-prone when several properties each need their own timing. The transition shorthand combines all four into a single declaration per transition, with multiple transitions separated by commas, and it is the form you will see most often in real stylesheets.
A single transition in shorthand looks like this:
.button {
transition: background-color 0.3s ease-out 0.1s;
}
The order is property, duration, timing function, delay. Only the duration is strictly required in the shorthand's grammar, but in practice you should always name the property and duration, and include the timing function when it matters, because an unnamed property defaults to all and an unnamed timing function defaults to ease. Omitting the delay means zero delay, which is usually what you want. The order of duration and delay matters because both are times: the first time value is read as duration, the second as delay, so writing them in the wrong order silently swaps their meanings.
Multiple transitions are written as a comma-separated list, each a complete shorthand unit:
.card {
transition: transform 0.3s ease-out, opacity 0.3s ease-out 0.05s;
}
Here, the transform animates over 0.3 seconds with no delay, while the opacity animates over the same duration but starts 0.05 seconds later, producing a subtle stagger. Because each transition is self-contained, there is no risk of misaligned lists, which was the hazard of the longhand form. That self-containment is the shorthand's main advantage beyond brevity: each transition's timing is visible in one place, next to its property.
One caution applies when mixing shorthand and longhand. If you write a shorthand and then a longhand property after it, the longhand overrides the corresponding part of the shorthand, which can be intentional — for example, overriding only the delay — but it can also be accidental if you forget the shorthand already set that value. Conversely, a shorthand written after longhand declarations resets all the longhand values to their defaults, except those the shorthand itself sets. To avoid confusion, pick one form for a given rule and stay with it, or, if you must mix, place the overriding declaration last and comment why it is there.
The shorthand also makes it easier to spot a missing duration, because each transition is written as a unit and a unit without a time value stands out. In longhand form, a missing duration on one property among several is easy to overlook, especially when lists are long. Writing transitions as shorthand units, and reviewing them as units, is therefore a readability habit as much as a brevity habit, and it helps during code review.
Check: In transition: background-color 0.3s ease-out 0.1s, what is each value?
Section 15 — Hover transitions in practice
Hover transitions are the most common transitions a beginner will write, because hovering is the most common interaction on a desktop, and a well-judged hover transition is the difference between a button that feels alive and one that feels flat. The pattern is consistent: define the base style and the transition on the element, define the changed style on :hover, and let the browser animate between them in both directions.
A complete button example shows the pattern with several properties changing together:
.button {
background: #0066cc;
color: white;
transform: translateY(0);
transition: background-color 0.25s ease-out, transform 0.25s ease-out;
}
.button:hover {
background: #004499;
transform: translateY(-2px);
}
On hover, the button darkens and lifts by two pixels, both over a quarter of a second with an ease-out curve, so it responds quickly and settles gently. When the mouse leaves, both properties return over the same duration, because the transition lives on the base rule. The lift is small — two pixels is enough to suggest pressability without making the button appear to jump — and the colour change is a darker shade of the same hue, which reads as a state change rather than a different button. Those restrained choices are typical of good hover design: noticeable, but not distracting.
Hover transitions must also work for keyboard users, who do not hover. The same changed styles should usually apply on :focus-visible as well, so a visitor tabbing to the button sees the same feedback as a mouse user. That is both an accessibility requirement and a consistency one: a button that only responds to a mouse is a button that ignores a whole class of visitors. Chapter 14 covers focus styles in detail, but the principle belongs here too: design the state change once, and apply it to hover and focus together.
Touch devices complicate hover, because there is no hovering finger in the same sense as a mouse. On a touch screen, tapping a button may trigger a brief hover state, or none at all, depending on the browser. Hover transitions should therefore never be the only way to convey information: a button's purpose must be clear from its label and appearance without hovering, and its active or pressed state should also be styled, so touch visitors get feedback when they tap. Hover is an enhancement for mouse users, not a requirement for understanding.
Finally, keep hover transitions fast and subtle. A duration much longer than 300 milliseconds for a hover colour change will feel laggy, as if the button is slow to acknowledge the visitor. A movement larger than a few pixels can feel gimmicky. The best hover transitions are the ones visitors barely notice consciously, but whose absence they would feel as flatness. That invisibility is the goal: motion that serves the interaction, not motion that draws attention to itself.
Check: Why should a hover transition also apply on focus?
Section 16 — Animating transform and opacity
Not all CSS properties are equal when it comes to animation performance. Some properties can be animated smoothly because the browser can handle them on a specialised, fast path, while others force the browser to redo expensive layout work on every frame. The two properties that are almost always safe to animate are transform and opacity, and understanding why will guide almost every animation decision you make.
transform changes an element's position, size, rotation, or skew without changing its layout box. When you translate an element by 20 pixels, the browser does not need to reflow the page — to recalculate where every other element sits — because the element's layout position has not changed; only its painted position has. Similarly, scaling an element with transform: scale() changes its visual size without changing the space it occupies in the layout, so neighbouring elements do not shift. That separation of visual change from layout change is what makes transforms cheap: the browser can move the element's painted layer without redoing the page's geometry.
opacity is similarly cheap. Changing an element's transparency does not affect layout at all; it only affects how the element is composited — blended — with what is beneath it. The browser can adjust opacity on the element's layer without recalculating layout or repainting its content from scratch. Fading an element in or out with opacity is therefore smooth even on modest devices, which is why fades are a staple of interface animation.
Together, transform and opacity cover most interface motion needs: moving, scaling, rotating, fading, and combinations of those. A menu can slide in with a translate and fade with opacity. A card can lift with a small translate and a shadow change. A modal can scale up slightly while fading in. If you can express an animation using only those two properties, you usually should, because it will be smooth on a wider range of devices, including phones with limited processing power.
That does not mean other properties can never be animated — colour transitions, for example, are common and generally fine for small elements — but it means that layout-affecting properties, covered in the next section, should be approached with caution. When you are choosing how to animate something, ask first: can I achieve this with transform and opacity? If the answer is yes, you have chosen the performant path, and your animation is likely to stay smooth where a layout-based animation would stutter.
Opacity and transform also have the advantage of being well supported and well understood, so an animation using only them is unlikely to encounter a browser quirk or a surprising fallback. More exotic properties, or properties that require layout, are more likely to behave differently across browsers or to be affected by other styles on the page. Sticking to the safe pair for core motion reduces that risk, and leaves exotic properties for cases where their specific effect is genuinely needed.
Check: Why are transform and opacity cheap to animate?
Section 17 — Properties to avoid animating
If transform and opacity are the safe properties, the properties to avoid animating are those that affect layout: width, height, margin, padding, top, left, and similar properties that change an element's box or position in the document flow. Animating those properties forces the browser to recalculate layout on every frame of the animation, and layout recalculation is expensive, especially on a page with many elements.
To understand why, consider what happens when an element's width animates from 200 pixels to 400 pixels over half a second. At each frame — typically 60 times per second — the browser must work out the new width, then work out how that new width affects the element's children, its siblings, and possibly its ancestors, then repaint the affected areas, then composite the result to the screen. That chain of work, repeated 30 times for a half-second animation, is far heavier than moving a painted layer with a transform, and on a phone it can easily drop frames, producing a stuttering animation that feels worse than no animation at all.
The same logic applies to top and left for positioning. Moving an element by animating left from 0 to 100 pixels changes its layout position at each frame, triggering reflow. Moving it by animating transform: translateX() from 0 to 100 pixels changes only its painted position, with no reflow. The visual result is identical — the element ends up 100 pixels to the right — but the cost is dramatically different. That equivalence is a useful test: if a transform can produce the same visual result as a layout property, prefer the transform.
There are exceptions and workarounds. Animating a panel's height, for example, is a common need for accordions and dropdowns, and there is no transform that directly changes height. Later sections cover techniques for that case, including animating grid-template-rows and using opacity and transform to mask the change. Colour, shadow, and border-colour animations are also generally acceptable for small elements, because they trigger repaint but not reflow, and repaint of a small area is manageable. The rule is not "never animate anything except transform and opacity," but "avoid layout properties where a transform alternative exists, and be cautious with layout animations where it does not."
A practical habit is to check an animation on a real phone, not just on a desktop. A width animation that looks smooth on a powerful laptop may stutter on a phone, and that stutter is the visitor's experience. If it stutters, rework it with transforms; if it still stutters, simplify it or shorten it. Smoothness on the weakest device you support is the standard, not smoothness on your own machine.
Layout animations also have a knock-on effect on scrolling: if an animating element changes the page's height repeatedly, the scrollbar itself changes length during the animation, and content below the element jumps up and down. That jumping is disorienting, especially for a visitor trying to read content below the animation. Transform-based motion avoids that entirely, because the layout height never changes, and the page remains stable while the element moves visually.
Check: Why does animating width cost more than animating transform?
Section 18 — Multiple transitions on one element
Real interface elements rarely change only one property at a time. A card on hover might darken, lift, and deepen its shadow; a menu might slide and fade; a button might change colour and scale slightly. CSS handles that by letting one element have several transitions, each with its own property, duration, timing function, and delay, so each aspect of the change can be timed independently.
The shorthand form is the clearest way to write multiple transitions, because each transition is a self-contained unit separated by a comma:
.card {
transition: transform 0.3s ease-out,
box-shadow 0.3s ease-out,
background-color 0.2s ease-out;
}
Here, the lift and the shadow share a duration and curve, so they move together, while the colour changes slightly faster, so it settles first. That subtle difference — colour leading, movement following — can make a hover feel layered rather than flat, as if the card responds in stages. The differences should stay small, however: a colour that finishes a full second before the movement looks disconnected, while a difference of a tenth of a second feels like a single, nuanced change.
Multiple transitions also allow different delays, which produce staggering within a single element. A modal that fades in over 0.2 seconds while scaling over 0.3 seconds, with the scale starting 0.05 seconds later, feels more organic than both starting and ending together, because the eye follows the fade and then the growth. Again, the delays should be small and purposeful; large delays within one element make it feel as if parts of the element are lagging behind the rest.
A common mistake with multiple transitions is forgetting that the transition list applies to all changes of those properties, not just to hover. If a card's transform transitions on hover, it will also transition when a class change or a media query changes its transform for another reason, which may or may not be desired. If a property should change instantly in one context and gradually in another, you may need to scope the transition to a specific state, or to override it in that context. Thinking about all the ways a property can change, not just the hover case, prevents surprising animations.
Finally, keep the number of simultaneous transitions modest. Animating three properties is usually plenty; animating six or seven, especially layout-affecting ones, risks performance problems and visual noise. Restraint is a design skill here: choose the two or three changes that most communicate the state change, animate those, and let the rest change instantly. The visitor will perceive a smooth, intentional change, not a catalogue of effects.
Multiple transitions also need care with the transition-property list when using the longhand form, because a mismatch between the property list and the duration list can pair the wrong duration with the wrong property. The shorthand avoids that by keeping each property with its own timing, which is another reason to prefer shorthand for multiple transitions. If you do use longhand, double-check that the lists align by position, not merely by length.
Check: How do you give two properties different durations on the same element?
Section 19 — Transitions for focus and active states
Hover is not the only interactive state that benefits from transitions, and for accessibility it is not even the most important one. Focus — when an element is selected by keyboard navigation or by assistive technology — and active — when an element is being pressed — are states every visitor encounters, and transitioning them thoughtfully makes an interface feel consistent and considerate, rather than responsive only to a mouse.
Focus transitions work exactly like hover transitions: the base rule declares the transition, and the :focus-visible rule declares the changed values. A common pattern is a button or link whose outline or background shifts on focus, with a short transition so the change is noticeable but not distracting. The transition should be fast — 150 to 200 milliseconds is typical — because focus changes frequently as a visitor tabs through a page, and a slow focus transition would lag behind their navigation, making it hard to tell which element is currently focused. Speed matters more for focus than for hover, because focus is a moving target.
There is a subtlety with focus outlines specifically. The outline is an accessibility feature that shows which element is focused, and it must remain clearly visible. A transition that fades an outline in from nothing can make the focus indicator hard to see during the transition, which defeats its purpose. A better approach is to keep the outline always visible when focused, and to transition only supplementary properties, such as background colour or a subtle shadow, while the outline appears instantly. That way, the focus indicator is never ambiguous, and the transition adds polish without compromising clarity.
The active state — :active, when a button is pressed down — also benefits from a transition, but in the opposite direction: it should usually be faster than hover, or even instant, because it represents the moment of action. A button that lifts on hover might press down on active, returning to its original position or slightly below it, with a very short transition of 100 milliseconds or less. That quick press gives tactile feedback, mimicking a physical button, and it reassures the visitor that their press registered. A slow active transition, by contrast, feels like the button is resisting the press.
Together, hover, focus, and active transitions form a small language of feedback: hover suggests, focus indicates, active confirms. Designing all three, with appropriate speeds and shared properties, makes an interface feel coherent to mouse, keyboard, and touch visitors alike, rather than polished for one input and abrupt for the others.
Active transitions also differ on touch devices, where the active state is brief, lasting only while the finger is down. A long active transition on touch may still be running after the finger lifts, making the button appear stuck in its pressed state. Keeping active transitions very short, or making the pressed state change instantly on touch, avoids that lingering effect and keeps the button feeling responsive to the actual press duration.
Check: Why should a focus transition be faster than a hover transition?
Section 20 — When to use a transition, and when not to
Transitions are so easy to add that it is tempting to add them everywhere, but not every change should be gradual. Knowing when not to transition is as important as knowing how, because unnecessary motion distracts, slows perceived responsiveness, and can cause real harm to visitors who are sensitive to motion. A good rule is to transition a change only when gradualness helps the visitor understand what happened.
Transitions help when a change would otherwise be confusing. A menu that appears instantly in a new location can disorient, because the visitor does not see where it came from; a menu that slides and fades from its trigger is easier to follow. A card that changes colour instantly on hover can feel abrupt, while a short fade feels like a response. A theme switch that cross-fades is gentler than an instant inversion. In each case, the transition communicates a relationship — this element became that element, this state followed that state — and that communication is its value.
Transitions do not help when the visitor needs information immediately. An error message, a confirmation, a price change, or a search result should appear promptly and clearly, not fade in over a second while the visitor wonders whether anything happened. A transition on such content delays understanding, and if the visitor is in a hurry or anxious — as they often are when an error appears — that delay feels like obstruction. Critical information should be instant, even if surrounding decoration animates.
Transitions also do not help when they are applied indiscriminately. A page where every element animates on every change — text resizing, images sliding, backgrounds shifting — becomes a spectacle rather than an interface, and the visitor's attention is pulled to motion rather than to content. The all keyword is a frequent culprit here, animating properties that were never intended to move. Restraint, again, is the answer: animate the few changes that carry meaning, and let the rest be instant.
Finally, transitions should respect the visitor's motion preferences, a subject covered in detail later in this chapter. A visitor who has asked for reduced motion should not be subjected to transitions that are merely decorative, and even meaningful transitions should be shortened or replaced with instant changes for them. That respect is not an optional extra; it is part of designing motion responsibly, and it begins with the decision, for each transition, of whether gradualness serves the visitor or merely decorates the page.
Transitions should also be considered in terms of repetition: a transition that is delightful the first time can become tiresome the fiftieth time, if it is applied to an action the visitor performs constantly, such as hovering over navigation links. For frequent actions, shorter and subtler transitions are better, because their cost is paid repeatedly. Reserve longer, more expressive transitions for infrequent, meaningful moments, where their novelty is a benefit rather than a burden.
Check: When is an instant change better than a gradual transition?
Section 21 — What a keyframe animation is
A transition smooths a change between two states, but sometimes a design needs more than two states, or needs motion that happens on its own without a state change to trigger it. That is what a keyframe animation does. An animation is a set of instructions, written with @keyframes, that describes how an element should change over time, through as many intermediate steps as you wish, and it can run automatically when the page loads, repeat, reverse, or pause, without waiting for a hover or a class change.
The basic structure has two parts. First, the keyframes rule names the animation and describes its steps:
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
Second, an element uses that animation by naming it and giving it a duration:
.notice {
animation: fade-in 0.5s ease-out;
}
When the page loads, the notice fades from transparent to fully visible over half a second. No hover, no JavaScript, no class change was needed; the animation runs because the element has an animation assigned. That autonomy is the key difference from a transition: a transition waits for a change, while an animation defines its own timeline and runs when its element appears, or when its animation properties change.
Keyframes can describe far more than a fade. A loading spinner rotates continuously, a notification slides in from the side and then settles, a progress indicator moves in stages, a character bounces, a background shifts colour through several hues. Each of those is a sequence of values over time, which is exactly what keyframes express. A transition, limited to a start and an end, cannot express a bounce with an overshoot, or a colour that passes through three intermediate hues, or a movement that pauses halfway. Animations can, because they can have as many keyframes as the motion requires.
Animations also have their own set of properties — duration, timing function, delay, iteration count, direction, fill mode, and play state — which later sections cover one by one. Together, those properties give animations a vocabulary for repetition and reversal that transitions lack. A transition is a single journey from one state to another; an animation is a performance that can repeat, alternate, pause, and hold its final pose. Learning to use that vocabulary well is learning to choreograph motion, not merely to smooth it.
Animations also differ from transitions in that they can run without any change to the element's ordinary styles: the keyframes themselves define the values, so an element can animate from its ordinary appearance through a sequence and back, without needing a hover state or a class to define the end state. That self-containment makes animations suitable for ambient motion, such as a gently pulsing indicator, where there is no state change to smooth, only a motion to perform.
Check: What is the main difference between a transition and a keyframe animation?
Section 22 — from, to, and percentage keyframes
Keyframe rules describe an animation as a series of stops along a timeline, and there are two ways to write those stops: the keywords from and to, and percentages. Both describe the same idea — what the element should look like at a given point in the animation — but percentages allow more stops, and therefore more complex motion, while from and to suit simple two-state animations.
from means the start of the animation, at 0% of its duration, and to means the end, at 100%. A fade-in animation written with keywords is short and readable:
@keyframes fade-in {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
That animation fades the element in while lifting it slightly into place, over whatever duration the element's animation property specifies. The two stops are enough, because the motion is a simple journey from hidden and low to visible and in place, with no intermediate event worth naming.
Percentages become necessary when the motion has intermediate events. A bounce, for example, might go down, up past its resting point, down again smaller, and settle:
@keyframes bounce {
0% { transform: translateY(0); }
40% { transform: translateY(20px); }
60% { transform: translateY(-10px); }
80% { transform: translateY(5px); }
100% { transform: translateY(0); }
}
Each percentage is a moment in the animation's duration: at 40% of the way through, the element is 20 pixels down; at 60%, it is 10 pixels up; and so on. The browser interpolates smoothly between those stops, producing the bounce. You can mix percentages and keywords in one rule if you wish — from is equivalent to 0% and to to 100% — but consistency is clearer: use keywords for two-stop animations and percentages for multi-stop ones.
A detail worth knowing is that you do not need to specify every property at every keyframe. If a keyframe omits a property, the browser interpolates that property from the nearest keyframes that do specify it, or from the element's ordinary styles if no keyframe specifies it. That allows you to animate one property in stages while another changes only from start to end, without repeating values unnecessarily. It also means you should check that omitted properties behave as you expect, especially in complex animations where an omission can produce a surprising interpolation.
Finally, keyframes are global: a @keyframes rule defined once can be used by any element, just like a variable defined in :root. That reusability is a strength — one bounce, one fade, one slide can serve many elements — but it also means keyframe names should be descriptive and unlikely to collide with other animations in a large project. Prefixing names with the component they belong to is a simple habit that prevents two animations from accidentally sharing a name.
Check: When should you use percentages instead of from and to?
Section 23 — animation-duration and iteration-count
An animation's duration and iteration count together determine how long it runs and how many times it repeats, and they are among the most straightforward animation properties to understand, though their interaction with other properties deserves attention. Duration, set with animation-duration, is the time for one complete run of the keyframes, from 0% to 100%, in seconds or milliseconds, just like transition duration. Iteration count, set with animation-iteration-count, is how many times that run repeats.
A simple example makes the interaction clear. An animation with a duration of 1 second and an iteration count of 3 runs for 3 seconds in total: it plays its keyframes from start to finish, then immediately starts again, then again, for three runs. An iteration count of 1 — the default — plays once and stops. An iteration count of infinite plays forever, until the animation is removed, paused, or the element leaves the page. Infinite animations suit continuous motion, such as a spinner rotating or a subtle pulsing indicator, but they should be used sparingly, because perpetual motion draws attention continuously and can be distracting or, for some visitors, distressing.
Fractional iteration counts are allowed, and they produce a partial final run. An iteration count of 2.5 plays twice fully and then halfway through a third run, ending at the 50% keyframe. That can be useful for an animation that should end in a middle state, though it is rarely needed for interface work, where whole counts are clearer. A count of 0 means the animation does not run at all, which is occasionally useful for disabling an animation in a particular state without removing its other properties.
Duration and iteration count interact with timing functions in a way worth noting: the timing function applies to each iteration, not to the whole animation. A 1-second ease-out animation repeated three times eases out three times, once per run, rather than easing once over three seconds. That per-iteration behaviour is usually what you want — each bounce eases the same way — but it surprises beginners who expect the timing to stretch across all repetitions. If you want a single easing over a longer period, use a longer duration with one iteration, not a short duration repeated.
Choosing duration and count is a design judgement. A one-time entrance animation of 0.5 seconds feels snappy; the same animation at 2 seconds feels slow and self-conscious. An infinite animation should be subtle enough to ignore, because it will be present for the visitor's entire stay on the page. Test durations by watching the animation in context, not in isolation, because an animation that feels right on its own can feel too long when it delays a visitor's task.
Check: How long does a 1-second animation with an iteration count of 3 run in total?
Section 24 — animation-direction: normal, reverse and alternate
By default, an animation plays forward, from its first keyframe to its last, and if it repeats, it jumps back to the start and plays forward again. That jump can be jarring, especially for a movement animation: an element slides right, then instantly teleports back to the left and slides right again. The animation-direction property controls that behaviour, offering ways to play backwards or to alternate direction on each repetition, so repeated motion feels continuous rather than jumpy.
The default value, normal, plays forward every time, with a jump back to the start between iterations. It suits animations where the jump is invisible or intentional — a fade-in that repeats as a pulse, for example, where the element fades out and then fades in again, and the reset is part of the effect. For a fade, the jump from fully visible to fully transparent at the end of an iteration is the fade-out itself, so normal feels natural.
reverse plays the keyframes backwards, from last to first, on every iteration. It is less commonly used on its own, but it is useful when you have an animation defined in one direction and need its mirror — for example, an element that should slide out using the same keyframes as an element that slides in, but in reverse. Rather than writing a second keyframes rule, you can reuse the first with reverse, which keeps the stylesheet shorter and ensures the two motions are exact mirrors.
alternate is the most useful value for continuous motion. It plays forward on odd iterations and backwards on even ones, so an element slides right, then slides back left, then right again, without ever teleporting. That back-and-forth feels like a pendulum or a gentle sway, and it is ideal for subtle ambient motion, such as an icon rocking gently or a highlight moving across a surface and back. Its counterpart, alternate-reverse, starts backwards and then alternates, which suits cases where the first movement should be in the reverse direction.
Direction interacts with fill mode, covered in the next section, because the "start" and "end" of an animation depend on its direction. With alternate, the animation ends in a different state depending on whether its iteration count is odd or even, which can surprise you if you expect it to always end at the final keyframe. Checking the end state for your chosen count and direction, rather than assuming it, prevents that surprise, especially for animations that should settle into a particular pose when they finish.
Direction also affects how an animation looks when it is paused mid-iteration, because a paused alternate animation may be travelling in either direction depending on its iteration count. That is not a problem in practice, since pausing freezes the current visual state regardless of direction, but it is worth knowing when reasoning about where an animation will end. If the end state matters, test with the exact iteration count and direction you intend to ship, rather than assuming it.
Check: Why does alternate feel smoother than normal for a sliding animation?
Section 25 — animation-fill-mode
When an animation is not running — before it starts, because of a delay, or after it finishes — the element returns to its ordinary styles, unless a fill mode says otherwise. That return can be jarring: an element that fades in over half a second, after a delay of one second, is fully visible during the delay if no fill mode is set, then jumps to transparent when the animation starts, then fades in. Fill mode controls what the element looks like during those non-running periods, and getting it right is essential for polished animations.
The default value, none, means the animation has no effect outside its running period. Before it starts, the element shows its ordinary styles; after it finishes, it returns to them. That is fine for animations that start immediately and whose start and end states match the ordinary styles, such as a pulse that begins and ends at full opacity. For other animations, none produces jumps at the start, the end, or both.
forwards means that after the animation finishes, the element keeps the styles from its final keyframe, rather than returning to its ordinary styles. A notification that slides in and should stay in place after sliding should use forwards, so it does not jump back to its starting position when the animation ends. Without forwards, the notification would slide in and then instantly teleport back to where it started, which is clearly wrong. forwards is therefore the most common fill mode for one-time entrance animations.
backwards means that during a delay before the animation starts, the element shows the styles from its first keyframe, rather than its ordinary styles. The fading element with a one-second delay, given backwards, is transparent during the delay, then fades in as intended, with no jump at the start. Without it, the element is visible during the delay and then jumps to transparent, which looks like a flicker. backwards is the fix for delayed animations that should be hidden until they begin.
both combines the two: the first keyframe's styles apply during the delay, and the final keyframe's styles persist after the finish. It is the safest choice for most entrance animations with a delay, because it handles both ends. A common pattern for a staggered entrance — several elements fading in with increasing delays — uses both, so each element is hidden until its turn and stays visible afterwards. Remembering both as the default for entrance animations will prevent a large class of flicker and jump bugs.
Fill mode also interacts with delay in a way that is easy to forget: without a fill mode, an element with a delay shows its ordinary styles during the delay, which may be fully visible, even though its first keyframe is transparent. That mismatch produces the flicker described earlier, and it is the most common fill-mode bug. Whenever you add a delay to an entrance animation, check whether backwards or both is also needed, and add it as a matter of routine.
Check: What problem does animation-fill-mode: backwards solve?
Section 26 — Animation delay, timing and shorthand
Animation delay, timing function, and shorthand work much like their transition counterparts, but with animation-specific details worth learning. Delay, set with animation-delay, postpones the start of an animation after the element is rendered or after its animation properties change. A positive delay means the animation waits; a negative delay means it starts partway through, as if it had already been running. Negative delays are a useful trick: an animation with a duration of 2 seconds and a delay of -1 second begins at its 50% keyframe, which is perfect for elements that should appear mid-motion, such as a set of spinners that should not all start in sync.
Timing function, set with animation-timing-function, shapes how the animation progresses within each iteration, just as for transitions, with the same keywords — ease, linear, ease-in, ease-out, ease-in-out — and the same custom options. For animations, linear is more common than for transitions, because continuous motion such as rotation should move at a constant rate; an easing spinner that speeds up and slows down each revolution looks uneven. Conversely, a bounce or a slide-in usually wants an easing curve, so its landing feels natural. Choosing the curve per animation, rather than defaulting to ease everywhere, is part of motion design.
The shorthand, animation, combines name, duration, timing function, delay, iteration count, direction, fill mode, and play state into one declaration. A typical example is:
.notice {
animation: fade-in 0.5s ease-out 0.2s both;
}
That reads as: run the fade-in keyframes, over 0.5 seconds, with an ease-out curve, after a 0.2-second delay, applying both fill modes. The order is flexible in the grammar, except that duration must come before delay when both are times, because the first time is read as duration and the second as delay. Omitting values leaves them at their defaults — one iteration, normal direction, no fill — so a shorthand that omits fill mode behaves like none, which is a common source of the jump bugs discussed in the previous section. Writing the fill mode explicitly, even when it is both, is a good habit.
As with transitions, mixing shorthand and longhand can override unexpectedly: a shorthand resets all animation properties to defaults except those it sets, so a longhand fill mode written before a shorthand will be lost. Keep one form per rule, or place overrides after the shorthand with a comment explaining why, and the animation's timing will remain predictable.
Animation shorthand also has a parsing detail worth remembering: if you write two time values, the first is duration and the second is delay, but if you write only one, it is duration, with no delay. That rule is simple, but it means a shorthand intended to set only a delay, without a duration, cannot be written as a single time value; you must write both, with the duration first. Forgetting that rule is a common cause of animations that run for an unexpected length.
Check: What does a negative animation delay do?
Section 27 — Transform: translate, scale and rotate
The transform property is the workhorse of CSS motion, because it moves, resizes, and rotates elements without affecting layout, as established earlier. Its three most used functions are translate(), scale(), and rotate(), and each deserves to be understood precisely, because they are combined in almost every animation and transition you will write.
translate() moves an element from its layout position by a given amount, without changing the space it occupies. translateX(20px) moves it 20 pixels right, translateY(-10px) moves it 10 pixels up, and translate(20px, -10px) does both. Percentages in translate refer to the element's own size, not its parent's, so translateX(50%) moves an element by half its own width — a useful fact for centring, where translate(-50%, -50%) combined with absolute positioning centres an element exactly. Because translate does not affect layout, neighbours do not shift when an element translates, which is why it is the preferred way to move things in animations.
scale() resizes an element visually, again without changing its layout box. scale(1.5) makes it one and a half times larger in both dimensions, scale(0.5) halves it, and scale(1.2, 0.8) widens it while shortening it. Scaling is often used for hover emphasis — a card growing slightly on hover — or for entrance animations, where an element scales up from 0.9 to 1 while fading in, giving a sense of arriving. The scaling origin, set with transform-origin, controls the point from which scaling happens: the centre by default, but a corner or an edge can produce different effects, such as a menu growing from its top-left corner.
rotate() turns an element around its origin by an angle, in degrees. rotate(45deg) turns it 45 degrees clockwise, rotate(-90deg) a quarter turn anticlockwise. Rotation suits icons, loading spinners, and playful accents, but it should be used sparingly in interfaces, because rotated text is harder to read and rotated controls can confuse. A small rotation — a few degrees on a decorative element, or a full rotation on a spinner — is usually enough; large rotations on functional elements rarely help.
These functions can be combined in one transform declaration, and their order matters, as the next sections explore. For now, the key point is that translate, scale, and rotate give you movement, size, and orientation, all without layout cost, and together they cover the vast majority of motion you will ever need.
Transform origin deserves a brief note here, because it changes how scale and rotate appear, even when the transform functions themselves are unchanged. Scaling from the centre grows equally in all directions, while scaling from the top-left corner grows only to the right and down, which can be useful for a menu expanding from its trigger. Setting transform-origin deliberately, rather than accepting the default centre, is often the difference between an animation that feels anchored and one that feels adrift.
Check: Why does translating an element not move its neighbours?
Section 28 — Opacity fades
Opacity is the simplest animation property, and also one of the most useful, because fading is the gentlest way to introduce or remove content. An element's opacity ranges from 0, fully transparent and invisible, to 1, fully opaque. Animating between those values, or between intermediate values such as 0.5, produces a fade, and fades are appropriate in a remarkable number of situations: a modal appearing, a notification arriving, an image cross-fading, a tooltip showing, a page section revealing as it scrolls into view.
A fade's virtue is its unobtrusiveness. Unlike a slide, which draws the eye to movement, or a scale, which changes an element's apparent size, a fade changes only visibility, so it does not distract from the content itself. That makes fades the safest default for entrance and exit animations, especially for content the visitor needs to read. A notification that fades in over 300 milliseconds is noticed without being startling; a notification that slides across the screen over the same time demands more attention, which may not be warranted for a minor message. Choosing a fade when the message is routine, and reserving more dramatic motion for genuinely important events, is a form of visual hierarchy.
Opacity also combines well with transform, as many examples in this chapter show. An element that fades in while translating up slightly — from opacity: 0; transform: translateY(10px); to opacity: 1; transform: translateY(0); — feels more alive than a pure fade, because the movement suggests arrival, while the fade keeps it gentle. The combination is so common that it has become a standard entrance pattern, and it is a good default for modals, cards, and notifications. Keep the movement small, however: a 10-pixel rise is elegant, while a 100-pixel rise is a slide, with all the attention that implies.
There is an accessibility detail worth knowing. An element with opacity: 0 is still present in the page: it can still be focused by keyboard, read by a screen reader, and clicked, unless it is also hidden with visibility: hidden or removed from the layout. A fade-out that leaves an invisible element interactive is a trap for keyboard users, who may tab to something they cannot see. Later sections cover how to combine opacity with visibility to avoid that trap, but the principle belongs here: fading is a visual change, not a removal, and an element that should be gone must be made genuinely unavailable, not merely transparent.
Opacity fades also work well for cross-fading between two elements, such as an image gallery where one image fades out as the next fades in. That cross-fade requires both images to be positioned in the same place, usually with absolute positioning, so they overlap while their opacities trade places. The result is smoother than a hard cut, and it avoids the layout shift that would occur if images were swapped in normal flow, because the container's size remains constant throughout.
Check: Why is a fade often a better default than a slide for routine content?
Section 29 — Combining transforms, and why order matters
Transforms can be combined in a single declaration, and the order in which you list them changes the result, which surprises many beginners who expect the functions to be independent. The reason is that each transform is applied in sequence, and each one changes the coordinate system for the ones that follow. Rotating and then translating is not the same as translating and then rotating, because the rotation changes which direction "right" means for the translation that follows it.
A concrete example makes this clear. Consider an element that should move right by 100 pixels and rotate by 90 degrees. Written as transform: translateX(100px) rotate(90deg);, the element first moves right, then rotates in place, ending up 100 pixels right and turned sideways. Written as transform: rotate(90deg) translateX(100px);, the element first rotates, so its local x-axis now points downwards, and then translates along that rotated axis, ending up 100 pixels down rather than right. Same functions, same values, different result, because the order changed the meaning of the translation.
That behaviour is not a quirk to memorise as a curiosity; it affects everyday animations. A card that scales and then translates will move by a scaled amount if the translation comes after the scaling, because the coordinate system has been scaled. A menu that rotates and then translates may end up in an unexpected place if the author assumed the translation was in page coordinates. The safe habit is to think of transforms as a sequence of operations applied left to right, each affecting the next, and to test combinations visually rather than assuming they commute.
In practice, most combinations are simple and order-insensitive in effect, because they involve only translate and scale in ways that do not interact badly, or because the values are small. A hover lift combined with a slight scale — transform: translateY(-2px) scale(1.02); — behaves sensibly in either order for small values, though the precise result still differs mathematically. For larger or more complex combinations, especially those involving rotation, write the order deliberately, comment it if it is non-obvious, and verify the result at several points in the animation, not just at its end, because an intermediate frame may reveal an unexpected path.
Finally, remember that a transform declaration replaces any previous transform on the element; it does not add to it. If an element has transform: translateX(10px); in its base rule and transform: scale(1.5); on hover, the hover state loses the translation entirely, because the hover transform replaces the base one. To keep both, the hover rule must list both functions. That replacement behaviour is a common source of "my element jumped" bugs, and it is worth checking whenever a transform seems to disappear on hover or during an animation.
Check: Why does rotate(90deg) translateX(100px) not move an element to the right?
Section 30 — steps() and discrete motion
Most animations move smoothly, interpolating gradually between keyframes, but some motion should move in discrete jumps rather than gliding. The steps() timing function provides that: instead of interpolating continuously, it divides an animation into a set number of equal steps, holding each step's value until the next step begins, then jumping to it. That jumpy behaviour is exactly right for certain effects, and wrong for most others, so knowing when to use it matters.
The classic use is a sprite animation, where a single image contains several frames of a character or an icon side by side, and the animation shifts the image's position to show each frame in turn. With a smooth timing function, the image would slide continuously, showing blurred intermediate positions that are not real frames. With steps(4), the image jumps cleanly from frame to frame, four jumps for four frames, producing a convincing animation from a static strip. The same technique works for a typewriter effect, where text appears letter by letter, or a clock hand that ticks rather than sweeps.
steps() takes two arguments: the number of steps, and a keyword controlling when the jump happens within each step. steps(4, end) — the default — holds each value until the end of its step, then jumps; steps(4, start) jumps at the start of each step. The difference is subtle but visible: with end, the first frame shows for a full step before moving; with start, the animation jumps immediately to the second frame. For sprite animations, end is usually correct, because the first frame should be visible for its full duration. Trying both with a simple example is the fastest way to feel the difference.
Steps are also useful for non-sprite effects where a smooth change would be misleading. A progress indicator that should show distinct stages, a counter that should tick in whole numbers, or a toggle that should snap rather than glide can all use steps to signal discreteness. In each case, the jumpiness is information: it tells the visitor that the change happens in stages, not continuously. Using a smooth animation for a staged change, by contrast, can imply a continuity that does not exist.
The caution is that steps should not be used for ordinary interface motion, where smoothness is expected. A button that jumps in steps when hovered, or a menu that stutters open in jumps, will feel broken rather than intentional, because the visitor expects a glide. Reserve steps for cases where the underlying thing being animated is genuinely discrete — frames, letters, stages — and the jumpiness will read as deliberate rather than as a bug.
Steps can also be combined with other timing functions in a single animation by using different timing functions for different properties, though that is an advanced technique. More commonly, steps is used on its own for a single property, such as background-position for a sprite, while other properties of the same element animate smoothly. That combination — one property stepping, another gliding — can produce effects like a character walking smoothly across the screen while its legs animate in discrete frames.
Check: When is a jumpy steps() animation more correct than a smooth one?
Section 31 — A loading spinner with keyframes
A loading spinner is an ideal first keyframe animation to build, because it is useful, self-contained, and demonstrates several animation properties in a small example. A spinner is typically a circle with a border that is coloured on one side only, rotating continuously to signal that something is happening. It does not need JavaScript to animate; CSS alone rotates it, and JavaScript only shows or hides it when loading starts or ends.
The markup is minimal: a single element, often a div with a class and an accessible label, because a spinner on its own conveys no meaning to a screen reader. The CSS gives it a size, a circular shape with border-radius: 50%, a light border on all sides, and a darker border on the top, so rotation is visible as the dark segment moves around the circle:
.spinner {
width: 40px;
height: 40px;
border-radius: 50%;
border: 4px solid #ddd;
border-top-color: #0066cc;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
Several details in that example are worth noting. The timing function is linear, because a spinner should rotate at a constant rate; an easing curve would make it speed up and slow down each revolution, which looks uneven and unprofessional. The iteration count is infinite, because loading may take an unknown time, and the spinner should continue until loading finishes. The keyframes use only to, because the starting state — no rotation — is the element's ordinary state, and the animation interpolates from there to a full turn. That brevity is idiomatic: you need only specify the keyframes that differ from the ordinary styles.
Accessibility deserves attention here too. A spinner should not be the only indication that loading is happening, because a screen reader user cannot see it. Pair it with text, such as "Loading…" in the page, or with a live region that announces the loading state, as Chapter 14 explains. Also consider visitors who prefer reduced motion: a spinning element is continuous motion, and for them it should be replaced with a static indicator or a much subtler one, using the media query covered later in this chapter. A spinner that respects those needs is a complete component, not just a rotating circle.
Finally, a spinner should be shown only while something is genuinely loading, and hidden promptly when it finishes. A spinner that spins forever because its hiding code failed is worse than no spinner, because it tells the visitor to wait for something that will never arrive. Pairing the CSS animation with correct showing and hiding logic — in JavaScript, or in whatever framework you use — is part of building the component properly, and it is a good example of CSS and JavaScript each doing the job they are best at.
Check: Why does a spinner use linear timing and infinite iteration?
Section 32 — Staggered animations with delay
When several similar elements animate in — a list of cards, a menu's items, a set of notifications — animating them all at exactly the same moment can feel flat, as if the whole group appeared at once. Staggering them, so each starts slightly after the last, makes the group feel as if it unfolds, which is easier for the eye to follow and more polished to watch. Delay is the tool for staggering, and it can be applied by hand or generated systematically.
The hand-written approach assigns increasing delays to each item, often with a class or a positional selector:
.list-item { animation: fade-in 0.4s ease-out both; }
.list-item:nth-child(2) { animation-delay: 0.1s; }
.list-item:nth-child(3) { animation-delay: 0.2s; }
.list-item:nth-child(4) { animation-delay: 0.3s; }
Each item fades in over 0.4 seconds, but the second starts a tenth of a second after the first, the third a tenth after the second, and so on. The total entrance for four items takes 0.7 seconds — the last item's delay plus its duration — which is still quick, but the sequence is perceptible. The fill mode both is important here, as discussed earlier, so items are hidden during their delay and stay visible afterwards; without it, they would be visible before their turn and then flicker.
The delay increment should stay small. Increments of 50 to 100 milliseconds produce a smooth cascade; increments of 300 milliseconds or more make the group feel slow, as if each item is waiting its turn in a queue, and a visitor trying to read the list must wait for the last item to appear. For a long list, staggering every item can become tedious, so a common refinement is to stagger only the first few items and let the rest appear together, or to cap the total stagger time. The goal is a sense of unfolding, not a parade.
Staggering can also be generated with variables, which scales better than hand-written delays. If each item defines --index for its position, a single rule can compute its delay: animation-delay: calc(var(--index) * 0.08s);. That pattern, combined with inline styles or generated markup setting --index per item, produces a cascade for any number of items without writing a rule per item. It is a good example of variables and animations working together, which the next section explores further.
Finally, staggering should respect reduced-motion preferences. A visitor who prefers reduced motion should get the content immediately, without a cascade, because the cascade delays information for decorative effect. The reduced-motion media query, covered later, can set all delays to zero and all durations to a minimal value, or remove the entrance animation entirely, so the content is simply present. That respect turns a decorative technique into a responsible one.
Check: How does staggering make a group entrance easier to follow?
Section 33 — Variables driving animations
CSS variables and animations work well together, because variables can supply the values an animation uses, and changing a variable can change an animation's behaviour without rewriting its keyframes. That combination is powerful for design systems, where the same animation should run faster in one context and slower in another, or move further on a large screen than on a small one, while keeping the same keyframes.
A simple example is duration. Suppose a fade-in animation is used throughout a site, but a modal should fade in slightly slower than a tooltip, because it is larger and more important. Instead of writing two keyframes rules, you can write one animation that uses a variable for its duration:
.fade-in {
animation: fade-in var(--fade-duration, 0.3s) ease-out both;
}
.modal { --fade-duration: 0.5s; }
.tooltip { --fade-duration: 0.15s; }
Each element's duration comes from its own --fade-duration, falling back to 0.3 seconds if none is set. The keyframes themselves are unchanged; only the timing adapts. That separation — keyframes describe the motion's shape, variables describe its timing — makes the animation reusable in contexts with different timing needs, without duplicating the shape.
Variables can also appear inside keyframes themselves, with a caveat. A keyframe can use var(), and the value is resolved from the element the animation applies to, at the time the animation runs. That means the same keyframes can move different elements by different amounts, if each element defines its own distance variable. For example, a slide-in keyframe using transform: translateX(var(--slide-distance, 20px)); will slide a sidebar by a large distance and a tooltip by a small one, depending on each element's variable. That flexibility is useful, but it requires care: if the variable changes mid-animation, the animation may not update as expected, because keyframe values are captured when the animation starts in some browsers. For values that change during an animation, JavaScript-driven updates or a new animation may be needed.
A further use is theming motion itself. A site might define --motion-duration globally, set it to a normal value by default, and reduce it to near zero for visitors who prefer reduced motion, using the media query covered later. Every animation that uses that variable then shortens automatically, without each animation needing its own reduced-motion override. That central control is a strong argument for routing animation timings through variables in larger projects, where motion consistency matters.
The general lesson is to treat variables as the parameters of your animations, and keyframes as the choreography. Parameters change with context; choreography stays the same. That separation keeps animations maintainable and adaptable, and it is a pattern you will recognise in many professional design systems.
Variables driving animations also help with responsive motion, because a media query can redefine a duration or distance variable for small screens, where shorter, smaller motion is often more appropriate. A large slide that feels dramatic on a desktop can feel excessive on a phone, where the screen is smaller and the visitor is more likely to be in a hurry. Redefining the variable at a breakpoint adapts the motion to the device, without duplicating the keyframes for each size.
Check: How can one set of keyframes produce different durations on different elements?
Section 34 — prefers-reduced-motion
Some visitors experience motion on a screen as uncomfortable, distracting, or even physically distressing, particularly those with vestibular disorders, which affect balance and spatial orientation. Operating systems therefore offer a "reduce motion" setting, and CSS exposes it through the prefers-reduced-motion media query, which lets a site respect that setting by reducing or removing its animations and transitions. Respecting it is not optional polish; it is an accessibility requirement for responsible motion design.
The query looks like this:
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
That broad rule effectively disables most animations and transitions for visitors who have asked for reduced motion, by making them so short as to be instant, and by limiting repetitions to one. The !important declarations ensure the override wins over ordinary animation rules, which is one of the few cases where !important is justified, because the visitor's preference should outweigh the designer's choices. A more nuanced approach disables only decorative animations while keeping essential ones — such as a progress indicator — but shortened, or replaces a slide with a fade, which is less likely to cause discomfort. Either approach is better than ignoring the setting.
It is important to understand what reduced motion does not mean. It does not mean "no motion at all, ever." A visitor who prefers reduced motion may still benefit from a subtle fade that indicates a state change, or from a progress animation that shows work is happening. The goal is to remove large, sweeping, or continuous motion — parallax scrolling, large slides, spinning, bouncing — and to keep motion small, brief, and purposeful. A fade of 100 milliseconds is very different from a slide across the screen over a second, and the media query lets you choose that difference deliberately.
Testing reduced motion is straightforward: enable the setting in your operating system or in browser developer tools, reload your page, and check that decorative motion is gone, that content still appears promptly, and that no information is lost because it was conveyed only by motion. A notification that only slides in, for example, must still appear as static content when motion is reduced. If your page passes that test, your motion design respects its visitors, and Chapter 14's broader accessibility principles are already being practised here.
Reduced-motion handling also applies to JavaScript-driven motion, such as parallax scrolling or scroll-triggered animations, which CSS media queries alone cannot disable if they are driven by script. In those cases, JavaScript should check the same preference, using matchMedia, and skip or simplify its motion accordingly. A site that respects reduced motion in CSS but ignores it in script has only half-respected the visitor's preference, and the remaining script motion may be the very motion that causes discomfort.
Check: What should a site do when a visitor prefers reduced motion?
Section 35 — Transitions for theme changes
When a site offers a theme switch — light to dark, for example — the change in colours can be jarring if it happens instantly across the whole page, especially at night, when a sudden inversion from dark to light can feel like a flash. A short transition on the properties that change with the theme softens that switch, making it feel like a deliberate cross-fade rather than a shock. That softening is a small detail, but it is one visitors notice and appreciate, particularly when they toggle themes frequently.
The technique is to add a transition to the elements whose colours change, typically on the body and on major surfaces, covering background colour and text colour:
body {
background: var(--surface);
color: var(--text);
transition: background-color 0.3s ease-out, color 0.3s ease-out;
}
When the theme class changes the variables, the background and text colours transition over 0.3 seconds, because the transition applies to changes in those properties, whatever their cause — including a variable change. That is a useful illustration of how transitions work: they do not care whether the change came from a hover, a class, or a variable; they smooth any change to a property they cover. The same transition will also smooth a hover colour change on the body, if one exists, which is usually harmless.
A broader approach applies the transition to many elements at once, for example with a rule that transitions background and colour on all elements during a theme change. That can produce a uniform cross-fade, but it must be used carefully: transitioning every element can cause performance issues on a large page, and it can also animate changes that should be instant, such as a focus indicator. A common compromise is to transition only the major surfaces — body, headers, cards, and navigation — and let smaller elements change instantly, which still feels like a smooth theme change without the cost of animating everything.
There is also a timing consideration. A theme transition should be short — 200 to 300 milliseconds is typical — because the visitor is waiting for the new theme to be readable, and a long cross-fade delays that readability. It should also respect reduced motion: for visitors who prefer reduced motion, the theme change should be instant, as the media query in the previous section ensures if it is applied broadly. A theme switch that is gentle for most visitors and instant for those who need it to be is a theme switch done well.
Theme transitions also need to consider images and other non-CSS content, which do not transition with background and text colours. A photograph that is bright in a light theme remains bright in a dark theme, which can make it stand out harshly against dark surroundings. Some sites dim images slightly in dark mode, or use a filter, to reduce that contrast, though that technique must be used carefully to avoid distorting the image's colours. The point is that a theme is more than its CSS colours, and its transition should consider the whole page.
Check: Why does a theme switch feel less jarring with a short transition?
Section 36 — Dark mode with variables and prefers-color-scheme
Beyond a manual theme toggle, many sites offer an automatic dark mode that follows the visitor's operating system preference, using the prefers-color-scheme media query. That query lets a stylesheet define one set of variables for light mode and another for dark mode, switching automatically when the visitor changes their system setting, without any button or JavaScript. It is a good example of variables and media queries working together, and it is increasingly expected by visitors who use dark mode system-wide.
The pattern mirrors the manual theme switch, but with a media query instead of a class:
:root {
--surface: white;
--text: #222222;
}
@media (prefers-color-scheme: dark) {
:root {
--surface: #1a1a1a;
--text: #f0f0f0;
}
}
A visitor whose system is set to dark gets the dark variables automatically, on first visit, with no toggle needed. A visitor in light mode gets the light variables. The components themselves do not change; they use --surface and --text in both modes, and the media query supplies the appropriate values. That is the same semantic-variable principle as manual theming: name variables by role, and let the values vary by context.
Manual and automatic theming can be combined. A common approach is to use the system preference as the default, and to let a manual toggle override it, with the toggle's choice stored so it persists across visits. That combination requires a little JavaScript to manage the precedence — system preference, then manual choice — and to apply the correct class or attribute. The CSS, however, remains the same: variables defined in :root, redefined under a dark class or a dark media query, with the manual choice taking precedence when present. Keeping the CSS layer simple, even as the JavaScript layer handles precedence, is a good separation of concerns.
Dark mode also requires design judgement beyond colour inversion. Simply darkening a light design can produce low contrast, muddy colours, or images that look out of place. Dark surfaces should not be pure black in most cases, because pure black against white text can cause halation — a glowing effect that strains the eyes — so very dark greys are usually better. Accent colours may need lightening to maintain contrast on dark backgrounds, and shadows, which are visible on light surfaces, may need to become glows or borders on dark ones. Testing both modes with real content, and checking contrast in each, is essential, because a dark mode that is hard to read is worse than no dark mode at all.
Dark mode variables also interact with form controls and other browser-rendered elements, which may follow the colour-scheme meta or CSS property. Setting color-scheme: light dark in CSS, or adding the corresponding meta tag, tells the browser that the page supports both schemes, so it can render scrollbars, form controls, and other native elements appropriately for the current scheme. Without that hint, a dark page may still show light form controls, breaking the immersion and, in some cases, readability.
Check: How does prefers-color-scheme: dark know which theme to apply?
Section 37 — will-change: a hint, not a guarantee
When an animation stutters, a tempting fix is to add will-change to the animated element, telling the browser in advance which property is about to change, so it can prepare — for example, by promoting the element to its own compositing layer. Used correctly, will-change can smooth an animation that would otherwise stutter, particularly on complex pages. Used carelessly, it can make performance worse, by consuming memory and forcing the browser to maintain layers for elements that never actually animate. Understanding that trade-off is the key to using it responsibly.
The syntax is simple: will-change: transform; hints that the element's transform is about to change, while will-change: transform, opacity; hints at both. The browser may respond by creating a separate layer for the element, so its transform and opacity changes can be composited without repainting surrounding content. That preparation takes time and memory, which is why it should be done only for elements that are about to animate, and removed when the animation finishes. A common pattern is to add will-change on hover, just before an animation, and to let it lapse afterwards, rather than leaving it on permanently.
Leaving will-change on many elements permanently is the main misuse. Each promoted layer consumes graphics memory, and on a page with dozens of such elements — a grid of cards, for example — the memory cost can be substantial, especially on a phone with limited resources. The browser may also be forced to keep those layers updated even when nothing is changing, which can slow scrolling and interaction. The specification itself warns against applying will-change to too many elements, and that warning should be taken seriously: it is a hint for imminent change, not a general performance booster.
In practice, most animations of transform and opacity do not need will-change at all, because modern browsers already handle those properties efficiently and promote layers when needed. will-change is worth trying only when you have measured a specific stutter — for example, an animation that drops frames on a real phone — and other fixes, such as simplifying the animation or reducing the number of animated elements, have not helped. In that case, add it narrowly, to the specific element and property, test whether it actually improves smoothness, and remove it if it does not. A hint that does not help is pure cost, and should not remain in the stylesheet.
Will-change also has a interaction with stacking and containing blocks: promoting an element to its own layer can change how its positioned descendants behave, in ways that are subtle and hard to predict. That is another reason to use it sparingly and to test thoroughly after adding it, checking not only the animation's smoothness but also the layout of surrounding elements. A performance hint that changes layout, even slightly, is not a free optimisation, and its side effects must be verified.
Check: Why should will-change not be left on many elements permanently?
Section 38 — Pausing with animation-play-state
Sometimes an animation should not run continuously, but should pause and resume in response to interaction or state. A carousel that pauses when the visitor hovers over it, a marquee that stops when it is focused, or a decorative animation that freezes while a modal is open are all examples of that need. The animation-play-state property provides it, with two values: running, the default, and paused, which freezes the animation at its current point in its timeline.
Pausing is different from removing an animation. When an animation is paused, the element stays at whatever point it had reached — halfway through a fade, a quarter turn through a rotation — and when it resumes, it continues from that point, rather than restarting. That continuity is what makes pausing feel natural: the animation is suspended, not reset. A carousel paused on hover, for example, stays on the slide it was showing, and resumes from there when the mouse leaves, rather than jumping back to the start. That behaviour is difficult to achieve by toggling classes or removing animations, which is why play state exists as a separate property.
A typical pattern pauses on hover and focus, so visitors who want to read content that is animating can stop it:
.carousel {
animation: slide 10s linear infinite;
}
.carousel:hover, .carousel:focus-within {
animation-play-state: paused;
}
That rule respects the visitor's intention: hovering or focusing signals interest in the current content, so the animation stops to let them read it. It also helps visitors who find continuous motion distracting, by giving them a simple way to stop it without changing a system setting. Pausing on hover alone is not a substitute for respecting reduced motion, however, because a visitor who cannot use a mouse, or who is overwhelmed before they can hover, still experiences the motion. Both mechanisms — pause on interaction and respect for reduced motion — belong in a complete design.
Play state can also be controlled by JavaScript, for example to pause an animation while a page is hidden or while a task is waiting, and to resume it when the page becomes visible again. That control is useful for performance, because an animation running in a hidden tab wastes resources, and for correctness, because an animation that should represent progress should not advance while its task is paused. In each case, the principle is the same: an animation is a timeline, and play state lets you suspend that timeline without losing its place.
Play state can also be used to create a stepped, controlled animation, where JavaScript pauses an animation at specific points to show content or to wait for user input, then resumes it. That pattern turns an animation into a kind of timeline that can be driven by interaction, rather than a purely automatic performance. It requires careful timing, because resuming at the wrong moment can feel abrupt, but it offers a level of control that simple start-and-stop cannot match.
Check: What happens to an animation's position when it is paused and then resumed?
Section 39 — Animation and transition events in JavaScript
CSS animations and transitions are visual, but JavaScript often needs to know when they finish — to remove an element after it fades out, to start a second animation after a first completes, or to update state when a transition ends. CSS provides events for that purpose, fired on the element when an animation or transition reaches a milestone, and JavaScript can listen for them just as it listens for clicks or key presses.
For transitions, the event is transitionend, fired when a transition completes for a particular property. Because an element can transition several properties at once, transitionend fires once per property, and the event object includes a propertyName telling you which property finished. That detail matters: if a card transitions both transform and opacity, listening for transitionend without checking propertyName will run your code twice, once for each property. Checking the property, or using a flag to run the code only once, avoids that duplication. There are also transitionstart, transitionrun, and transitioncancel events, but transitionend is the one most commonly needed.
For animations, the events are animationstart, animationend, and animationiteration, fired when an animation starts, finishes, or completes one iteration of a repeating animation. animationiteration is useful for counting repetitions or for changing something each time an animation loops, such as advancing a carousel slide. animationend is useful for chaining: when one animation ends, start the next, or remove the element that just finished animating out. As with transitions, the event includes the animation's name, so a listener can distinguish between several animations on the same element.
A common pattern is removing an element after a fade-out. Instead of removing it instantly, which would snap, you add a class that fades it out, listen for transitionend on opacity, and remove the element in the handler. That sequence — animate, then remove on completion — produces a smooth exit without leaving an invisible element in the page. The reverse pattern — adding an element, then animating it in — usually needs a small delay or a forced reflow between adding and animating, because the browser may otherwise batch the addition and the class change together and skip the animation. Those timing details are fiddly, but they are the price of coordinating CSS motion with JavaScript logic, and they become familiar with practice.
Events also have a caveat: if an animation or transition is interrupted — for example, by removing the element, or by changing its properties mid-animation — the end event may not fire, or a cancel event may fire instead. Code that depends on an end event should therefore have a fallback, such as a timeout that cleans up if the event never arrives, so the page does not get stuck waiting for an animation that will never complete.
Check: Why might a transitionend handler run more than once for one element?
Section 40 — The display problem: why transitions cannot fade display
One of the most frustrating discoveries for beginners is that a transition cannot animate display. An element that changes from display: none to display: block appears instantly, however long a transition is declared, and an element that changes from display: block to display: none disappears instantly, with no fade-out. That is not a bug in your code; it is a consequence of what display means, and understanding it will save you from a common dead end.
display is a discrete property: it has no intermediate values. An element is either displayed as a block, or not displayed at all; there is no halfway state between those, in the way there is a halfway colour between blue and dark blue, or a halfway opacity between 0 and 1. Transitions and animations work by interpolating between values, and where no intermediate values exist, there is nothing to interpolate. The browser therefore applies the change instantly, at the start or end of the transition period, depending on the direction, but without any gradual change in between. That is why a fade-in on an element that was display: none often appears to do nothing, or to snap: the display change happens instantly, and the opacity transition, if it runs at all, starts from an element that is already visible.
The practical workaround is to animate properties that do have intermediates, and to handle display separately. For an entrance, start with the element in the layout but invisible — opacity: 0 and perhaps visibility: hidden — then animate opacity to 1 and visibility to visible. For an exit, animate opacity to 0 first, then set display: none when the transition ends, using the transitionend event from the previous section, or by using visibility with a delay so it flips to hidden after the fade completes. Those patterns produce a smooth fade in both directions, without relying on display itself to animate.
Modern CSS has added features that ease this problem, such as allowing display in keyframe animations with a discrete interpolation, and the transition-behavior property, which can allow a discrete property to change at the end of a transition rather than at the start. Those features are newer and have varying support, so the opacity-plus-visibility pattern remains the reliable, widely supported approach for now. The lesson, however, is general: when a transition seems to do nothing, check whether the property you are animating is interpolable at all, because a discrete property will always snap, however carefully you time it.
The display problem also explains why many modal and menu implementations use a two-step process: first make the element displayed but invisible, then, in the next frame, add the class that animates it in. That two-step process, often implemented with requestAnimationFrame or a forced reflow, ensures the browser registers the starting state before the animation begins, so the transition actually runs. Without that step, the browser may batch the display change and the opacity change together, skipping the animation entirely.
Check: Why can display not be transitioned gradually?
Section 41 — Animating height without layout cost
Animating an element's height — for an accordion that expands, a dropdown that opens, or a panel that reveals more content — is one of the most requested animations, and also one of the trickiest, because height is a layout property. Animating it directly, from 0 to a fixed height, forces layout recalculation on every frame, as discussed earlier, and it requires knowing the final height in advance, which is often impossible when the content varies. Fortunately, there are techniques that achieve the effect without those costs, or with them minimised.
The modern technique uses grid. A container with display: grid and grid-template-rows: 0fr collapses its content to zero height, while grid-template-rows: 1fr lets it expand to its natural height, and crucially, grid-template-rows is animatable between those values in modern browsers, even though the natural height is unknown. The pattern looks like this:
.panel {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.3s ease-out;
}
.panel.open {
grid-template-rows: 1fr;
}
.panel > div { overflow: hidden; }
The inner div needs overflow: hidden so its content is clipped while the row is collapsed, rather than overflowing visibly. When the panel opens, the row animates from 0fr to 1fr, and the content reveals smoothly, without JavaScript measuring heights and without a fixed height in the CSS. That is a significant improvement over older techniques, which required JavaScript to read the content's height and set it explicitly, with all the fragility that measuring entails.
An older, still useful technique animates max-height instead of height. The collapsed state has max-height: 0, and the open state has a max-height larger than the content will ever be, such as 500 pixels, with overflow: hidden and a transition on max-height. That works in all browsers, but it has a drawback: the transition duration is calculated against the full max-height, not the actual content height, so short content animates slowly at first and then appears to finish early, because most of the max-height range is empty. Choosing a max-height close to the real content height reduces that effect, but it requires guessing the content's size, which is fragile when content changes.
A third approach sidesteps height entirely: fade and slightly translate the content in, while letting its height change instantly. The visitor perceives a smooth appearance, because the fade and movement draw the eye, even though the layout change itself is instant. That is often sufficient for small panels, and it avoids layout animation altogether. The choice among these techniques depends on the content, the browsers you support, and how important a true height animation is to the experience. For modern projects, the grid technique is the cleanest, with max-height as a fallback, and fade-plus-translate as a lightweight alternative.
Check: Why is animating grid-template-rows better than animating a fixed height?
Section 42 — Custom easing with cubic-bezier
The keyword timing functions — ease, ease-out, and their companions — cover most needs, but sometimes a motion needs a specific feel that no keyword provides: a bounce that overshoots its target, a movement that starts very slowly and then rushes, or a deceleration that is gentler than ease-out. The cubic-bezier() function lets you define a custom acceleration curve, with four numbers that describe the shape of the curve, giving you precise control over how an animation progresses through its duration.
A cubic-bezier curve is defined by two control points, each with an x and a y coordinate, written as cubic-bezier(x1, y1, x2, y2). The x coordinates represent time, from 0 at the start to 1 at the end, and must fall within that range. The y coordinates represent progress, from 0 at the start value to 1 at the end value, but they can go outside that range, which is what produces overshoot: a y value greater than 1 means the animation passes its target value before settling back to it, creating a bounce or spring effect. For example, cubic-bezier(0.34, 1.56, 0.64, 1) is a well-known ease-out-back curve that overshoots slightly before settling, giving a playful, springy feel to a pop-in animation.
Understanding the numbers in detail is less important than developing an intuition for them, and the best way to do that is with a visual tool. Browser developer tools include a cubic-bezier editor that shows the curve and lets you drag its control points while previewing the animation, and several websites offer similar editors with preset curves for common effects. Using such a tool, you can see immediately how moving a control point changes the feel: pulling the first point right delays the start, pushing the second point up accelerates the middle, pushing a y value beyond 1 creates overshoot. Experimenting in that visual way builds intuition far faster than memorising number combinations.
Custom curves should be used sparingly and purposefully. A springy overshoot suits a playful element, such as a notification badge popping in, but it would be distracting on a form field or a navigation menu, where a calm ease-out is more appropriate. A very slow start suits a large, dramatic entrance, but it would make a button hover feel unresponsive. The curve, like duration, should match the element's importance and the visitor's expectation: the more routine the interaction, the calmer the curve; the more celebratory the moment, the more expressive it can be.
Finally, custom curves can be stored in variables, just like durations, so a design system can define a small set of named curves — for example, --ease-standard, --ease-emphasis, --ease-spring — and reuse them consistently. That consistency is what makes a site's motion feel like a coherent language rather than a collection of unrelated effects, and it is the same principle as a colour or spacing scale, applied to time.
Check: What does a cubic-bezier y value greater than 1 produce?
Section 43 — Animating menus with opacity and transform
Menus, dropdowns, and tooltips are among the most animated elements on the web, and they are also among the easiest to animate badly, because they appear and disappear frequently, and a poor animation quickly becomes annoying. The reliable pattern for menus uses only opacity and transform, the performant properties, combined with visibility handling to ensure the hidden menu is genuinely unavailable, not merely transparent.
A dropdown menu, for example, might start hidden with opacity: 0, visibility: hidden, and transform: translateY(-8px), meaning it is transparent, unavailable, and slightly above its final position. When its trigger is hovered or focused, or when a class is added, it changes to opacity: 1, visibility: visible, and transform: translateY(0), with a transition on opacity and transform. The menu fades in while dropping gently into place, over about 200 milliseconds, which is fast enough not to delay the visitor, but smooth enough to show where the menu came from. The small translate — 8 pixels — is enough to suggest movement without distracting from the menu's content.
Visibility handling is the detail that makes this pattern correct. Opacity alone, as noted earlier, leaves an invisible element interactive, so a hidden menu that is only transparent could still be tabbed to or clicked, which would confuse keyboard users and produce invisible focus indicators. Adding visibility: hidden to the hidden state and visibility: visible to the shown state, with a transition that flips visibility at the appropriate moment, ensures the menu is truly unavailable when hidden and available when shown. Because visibility is a discrete property, its transition is handled by delaying its change until the fade completes on exit, or by changing it instantly on entry, which is a standard technique covered in more advanced material but worth noting here as the reason visibility appears in menu patterns.
Timing for menus deserves care in both directions. The entrance should be fast, so the visitor is not kept waiting for options they need. The exit can be slightly slower, or can have a short delay, so a mouse moving briefly off the menu does not instantly close it, which is a common frustration with hover menus. That exit delay must stay short — 100 milliseconds or so — or the menu will feel sticky and unresponsive. Testing menus with a real mouse, moving at natural speeds, is the best way to tune those timings, because a menu that feels right when tested deliberately can still flicker when used quickly.
Finally, menus must work without animation for visitors who prefer reduced motion, and without hover for keyboard and touch visitors. Those requirements, covered in earlier sections and in Chapter 14, are not separate from the animation pattern; they are part of designing a menu that is usable by everyone, with animation as an enhancement, not a requirement.
Check: Why must a hidden menu use visibility as well as opacity?
Section 44 — Motion for feedback: success, error and loading
Motion is not only decoration; it is also feedback, telling the visitor that an action succeeded, failed, or is still in progress. Used well, feedback motion reduces uncertainty and anxiety: a visitor who submits a form and sees a brief success animation knows their submission worked, without having to read a message carefully. Used badly, feedback motion confuses or alarms: a violent shake for a minor error can feel punitive, while a subtle change for a serious error can be missed. Designing feedback motion means matching the motion's intensity to the message's importance.
Success feedback is usually gentle and positive. A checkmark drawing itself, a button briefly turning green, a small scale pulse on a confirmation icon — these are calm, affirmative motions that confirm without demanding attention. They should be brief, typically under half a second, and they should not block the visitor from continuing. A success animation that takes two seconds and prevents further interaction is not feedback; it is an interruption, and visitors will resent it on repeated use. The best success motion is one the visitor barely registers consciously, but whose absence would leave them wondering whether anything happened.
Error feedback needs to be noticeable, but not aggressive. A field that shakes violently when its input is invalid may draw attention, but it can also feel like scolding, especially for visitors who are already frustrated by an error. A gentler approach is to highlight the field with a colour change, perhaps with a small shake of a few pixels or a brief emphasis animation, paired with a clear text message explaining the error and how to fix it. The text message is the essential part; the motion is only a pointer to it. An error that is conveyed only by motion — a shake with no message — fails visitors who cannot see the motion, or who do not know what it means, which is why text must always accompany error motion.
Loading feedback, as with the spinner earlier, should be continuous but unobtrusive, signalling progress without demanding focus. A spinner, a progress bar, or a skeleton placeholder — a grey shape mimicking the content that will appear — all serve that purpose, and the choice depends on how long the wait is expected to be. A spinner suits an indeterminate wait; a progress bar suits a measurable one; a skeleton suits content loading, because it previews the layout that will arrive. In each case, the motion should stop promptly when loading finishes, and should be replaced with the real content, not left spinning as a decoration.
In all three cases, the principle is the same: motion should answer a visitor's question — did it work, what went wrong, is it still working — as quickly and calmly as possible, and then get out of the way. Feedback motion that does that earns its place; feedback motion that draws attention to itself does not.
Check: Why should error motion always be paired with text?
Section 45 — Principles of subtle, purposeful motion
By now you have met many individual techniques — transitions, keyframes, easing, staggering, reduced motion — and it is worth stepping back to ask what principles tie them together. The most important principle is purpose: every animation should have a reason that can be stated in one sentence, such as "show where this menu came from," "confirm that the form was submitted," or "indicate that loading is in progress." If you cannot state the reason, the animation is probably decoration, and decoration should be minimal, because it competes with content for the visitor's attention.
The second principle is subtlety. Motion should be as small as it can be while still achieving its purpose. A lift of two pixels, a fade of 200 milliseconds, a slide of eight pixels — those are typical values for interface motion, and they feel professional precisely because they do not draw attention to themselves. Large, long, or dramatic motion has its place, in a hero animation or a celebratory moment, but using dramatic motion for routine interactions — every button, every card, every menu — quickly becomes exhausting, like a person who shouts every sentence. Subtlety is not timidity; it is respect for the visitor's attention, which is a limited resource.
The third principle is consistency. A site whose buttons fade in 200 milliseconds, whose menus slide in 300 milliseconds, and whose modals scale in 500 milliseconds with a spring curve feels incoherent, because each element moves by different rules. A site that uses a small set of durations and curves consistently — for example, 200 milliseconds for feedback, 300 for panels, and one ease-out curve throughout — feels coherent, because its motion has a language, just as its colours and spacing have a language. Variables, as discussed earlier, help enforce that consistency by naming durations and curves once and reusing them.
The fourth principle is responsiveness to the visitor, in both senses of the word: motion should respond quickly to input, and it should respect the visitor's preferences and device. A hover that takes a second to respond feels broken, however beautiful its curve; a continuous animation that ignores reduced motion feels inconsiderate, however subtle it is. Testing motion on a real device, with a real hand, and with reduced motion enabled, is how you check those principles in practice, rather than merely asserting them.
Holding those four principles — purpose, subtlety, consistency, and responsiveness — will guide you through motion decisions that no single technique can answer, from whether to animate at all, to how long and how far, to when to stop. They are the difference between a site that moves and a site that moves well.
Subtlety also means knowing when to remove motion entirely, not merely to shorten it. A decorative animation that adds nothing to understanding, however subtle, is still a demand on attention, and on a page with many such animations, the cumulative demand can be significant. Auditing a page's animations and removing those without a clear purpose is often more valuable than refining their durations, because it reduces the total motion load to only what serves the visitor.
Check: Name the four principles of purposeful motion from this section.
Section 46 — Variables inside keyframes, and their limits
Using variables inside keyframes is an attractive idea, because it promises reusable animations whose details adapt to context, as discussed in an earlier section. It is worth returning to that idea in more detail, because it has limits that can surprise you if you rely on it without understanding how browsers resolve variables during an animation. The limits do not make the technique useless, but they do shape when it is appropriate.
The basic behaviour is straightforward: when a keyframe contains var(), the browser resolves that variable from the element the animation applies to, using the element's current variable values when the animation starts. That means two elements using the same keyframes but different variable values will animate differently, which is the desired flexibility. A slide-in animation that uses translateX(var(--slide-distance, 20px)) will slide one element by 20 pixels and another by 100 pixels, if their variables differ. That is a genuine benefit, and it is how design systems create variant animations from a single keyframes rule.
The limit appears when a variable changes while the animation is running. In many browsers, keyframe values are captured at the start of the animation, so changing the variable mid-animation does not smoothly update the animation's path; the animation continues with its original values, or restarts, depending on the change. That means variables in keyframes are best used for values that are set before the animation begins and stay constant during it — a distance, a duration, a colour — rather than for values that are expected to change continuously, such as a position following a mouse. For continuously changing values, JavaScript-driven style updates, rather than a CSS animation, are the appropriate tool.
Another limit is that variables cannot be animated themselves in the same way as ordinary properties, except through the @property rule, which is a more advanced feature that lets you register a custom property with a type, so the browser knows how to interpolate it. Without registration, a variable's value changes instantly, not gradually, even if a transition is declared on it. That is why animating a variable directly — for example, transitioning --brand from blue to red — does not produce a smooth colour change in most browsers, unless the variable is registered with @property and a colour type. That feature is worth learning later, but for now, the practical rule is: use variables to parameterise animations before they start, and use ordinary properties for the values that actually animate.
Understanding those limits prevents a common frustration: writing a variable-driven animation, changing the variable interactively, and wondering why the animation does not follow. The animation is not broken; it is following its captured values, as designed. Designing with that behaviour in mind — setting variables before starting, and restarting an animation when its parameters change — turns a limit into a predictable pattern.
Check: Why might changing a variable mid-animation not update the animation smoothly?
Section 47 — Performance and compositing layers
Animation performance is ultimately about how the browser turns CSS changes into pixels on a screen, and understanding that process at a high level helps you diagnose stuttering animations and avoid creating them. The browser's rendering pipeline has several stages: layout, where element positions and sizes are calculated; paint, where colours, text, and images are drawn into layers; and compositing, where those layers are combined into the final image. Animating a property can trigger different stages, and the cost depends on which stages it triggers.
Layout-triggering properties, such as width and height, require the pipeline to start again from layout, which is the most expensive stage, because changing one element's size can affect many others. Paint-triggering properties, such as background colour or box shadow, require repainting the affected layer, which is cheaper than layout but still costly if the area is large. Compositing-only properties, such as transform and opacity, require only the final compositing stage, because the layer's content does not change, only its position or transparency. That is why transform and opacity are the cheapest properties to animate, and why the earlier advice to prefer them is not merely a preference but a consequence of the pipeline.
Compositing layers themselves deserve a note. The browser divides a page into layers, often promoting elements that animate with transform or opacity to their own layers, so their changes can be composited without repainting other content. That promotion is beneficial, but each layer consumes memory, and too many layers — for example, a page where every card is promoted — can exhaust graphics memory, especially on a phone, and actually slow the page down. That is the same trade-off as with will-change, which explicitly requests promotion: layers help when they are few and purposeful, and hurt when they are many and indiscriminate.
Practical performance work therefore follows a sequence: first, choose transform and opacity where possible, so the animation stays in the compositing stage. Second, keep the number of simultaneously animated elements small, so the browser is not compositing dozens of layers at once. Third, test on a real, modest device, because a desktop's graphics hardware can mask costs that a phone cannot. Fourth, use developer tools' performance panel if an animation still stutters, to see which stage is taking time, rather than guessing. That sequence — choose cheap properties, limit count, test on real hardware, measure when needed — resolves most performance problems without advanced techniques.
Compositing also explains why animating a large element, such as a full-screen background, can be costly even with transform and opacity, because the layer itself is large and compositing it each frame takes more work. Keeping animated layers as small as possible, and avoiding animating full-screen elements unnecessarily, is therefore part of performance work, even when the properties themselves are cheap. Size matters as well as property choice, and both should be considered together.
Check: Which rendering stage do transform and opacity animations avoid redoing?
Section 48 — Motion, visibility and keyboard access
Motion and accessibility intersect in a way that goes beyond reduced motion, because an animated element's visibility and interactivity must stay in sync, or keyboard and screen reader users can encounter elements that sighted mouse users cannot see, or vice versa. The most common failure is an element that is visually hidden by an animation — faded to transparent, slid off screen, or scaled to nothing — but remains in the page's tab order and accessibility tree, so a keyboard user can focus an invisible control, and a screen reader user can encounter content that is not visually present.
The fix is to pair visual hiding with genuine hiding, using visibility: hidden or display: none at the appropriate moment, as discussed for menus. For a fade-out, that means animating opacity to 0, then, when the transition ends, setting visibility to hidden or removing the element, so it is no longer focusable or announced. For a fade-in, it means starting with visibility hidden and opacity 0, then making it visible at the start of the animation, so it becomes focusable only when it is visible. Those two directions — hide after fading out, show before fading in — are the correct sequence, and reversing them produces either a flicker or an invisible focus trap.
A related issue is focus management during motion. When a modal animates in, focus should move into it, so keyboard users are not left tabbing through the page behind it. When it animates out, focus should return to the element that opened it, so the visitor is not left at an arbitrary place in the page. Animating the modal's appearance does not, by itself, move focus; that movement must be handled in JavaScript, timed to coincide with the animation's completion or start, so the visual and keyboard experiences align. Chapter 14 covers focus management in more detail, but the principle belongs here: motion changes what is visible, and focus must follow what is visible.
Another intersection is with content that moves. An element that slides or bounces continuously can be difficult to click or read, especially for visitors with motor or cognitive difficulties, even if they have not enabled reduced motion. Providing a way to pause or stop such motion — a pause button, a hover pause, or a static alternative — is a courtesy that benefits many visitors, not only those with a formal accessibility need. Motion that cannot be stopped, and that is not essential to the content, is a barrier, however beautiful it may be.
Visibility handling also matters for screen reader announcements: an element that becomes visible with an animation should be announced, or at least discoverable, at the right moment, not before it is visible or after it has disappeared. Timing announcements with animation completion, using the events from an earlier section, helps ensure that assistive technology and visual presentation stay in sync, so all visitors encounter the content at the same point in the interaction.
Check: Why must a faded-out element also become genuinely hidden?
Section 49 — Choosing durations: a practical guide
Choosing durations is one of the most frequent motion decisions, and beginners often choose them arbitrarily — a second here, two seconds there — without a sense of what feels right for each kind of change. A practical guide, based on common practice rather than a strict rule, can help you make those choices confidently, and then refine them by testing. The guide below groups durations by purpose, because the right duration depends on what the animation is for, not on a universal ideal.
For immediate feedback — a button press, a hover colour change, a focus shift — durations of 100 to 200 milliseconds feel responsive. The change is perceptible as a change, not as a snap, but it does not delay the visitor's sense that their action registered. A hover at 500 milliseconds, by contrast, feels laggy, as if the button is slow to acknowledge the mouse. Feedback durations should therefore be short, and they should be consistent across similar controls, so the interface feels uniformly responsive.
For panel and menu movements — a dropdown opening, a sidebar sliding in, a modal appearing — durations of 200 to 400 milliseconds are typical. Those movements cover more distance than a colour change, so they need more time to be followed, but they should still complete quickly enough that the visitor is not waiting for content they need. A menu that takes a full second to open will frustrate a visitor who opens menus repeatedly, while a menu that opens in 100 milliseconds may appear so abruptly that its origin is unclear. The middle range balances followability and speed.
For larger, more celebratory movements — a hero entrance, a page transition, a success celebration — durations of 500 to 800 milliseconds can be appropriate, because those moments are meant to be noticed and savoured, and the visitor is not waiting for functional content in the same way. Even here, restraint matters: a hero animation longer than a second will delay the visitor's reading, and a celebration longer than a second will feel self-indulgent on repeated viewing. Large durations should be reserved for first impressions and special moments, not for routine interactions.
Finally, remember that durations interact with distance and with the visitor's device. A movement over a large distance needs more time than the same movement over a small distance, if it is to be followed, and an animation that feels right on a desktop may feel slow on a phone, where the visitor is often in a hurry. Test durations in context, on a real device, with real content, and adjust them until they feel like a natural part of the interaction, not like a delay imposed on it. When in doubt, choose the shorter duration and check whether the movement is still readable.
Check: What duration range suits a menu opening, and why not a full second?
Section 50 — Chapter 13 in one paragraph
CSS variables turn repeated values into named decisions, declared globally in :root or locally on a component, inherited through the page, redefined by themes and media queries, and updated by JavaScript, so that a single change propagates wherever that decision is used, while transitions smooth the changes those decisions produce, by interpolating named properties over a chosen duration, with a timing curve and an optional delay, declared on the base rule so they work in both directions, and preferring transform and opacity for performance over layout-affecting properties that force expensive recalculation. Keyframe animations extend that smoothing into choreography, with named sequences of stops written as from and to or as percentages, controlled by duration, iteration, direction, fill mode, delay, and play state, and combined with transforms, opacity, staggering, and custom easing to produce spinners, entrances, menus, and feedback that feel intentional rather than accidental. Throughout, the same disciplines apply: choose durations and curves that match the interaction's importance, keep motion subtle and consistent, respect reduced-motion preferences and keyboard access by pairing visual hiding with genuine hiding, and test on real devices, because motion that is smooth, purposeful, and respectful is not decoration added to an interface, but part of the interface itself, communicating state, origin, and progress in a language visitors understand without needing to think about it, and that language, learned here, will serve every interactive page you build from now on. That language is learned not by memorising every property, but by practising the patterns in this chapter until they feel natural: naming a decision as a variable, smoothing a change with a transition, choreographing a sequence with keyframes, and always asking whether the motion helps the visitor understand, or merely decorates the page. Each pattern, used well, makes an interface feel more responsive, more polished, and more humane, because it acknowledges that a visitor is a person interacting in time, not merely viewing a static picture. A button that responds, a menu that arrives from its trigger, a theme that cross-fades rather than flashes, and a loading indicator that reassures rather than alarms are all small kindnesses, and they accumulate into an experience that feels considered. The opposite — motion that is arbitrary, excessive, or inconsiderate — also accumulates, into an experience that feels noisy and tiring, however technically impressive its individual animations may be. The choice between those two experiences is made in dozens of small decisions, about duration, distance, curve, and purpose, and this chapter has given you the vocabulary to make those decisions deliberately, and the habits to test them on real devices, with real hands, and with the preferences of real visitors in mind. Carry that vocabulary into your next project, and let motion serve understanding first, delight second, and never at the expense of clarity or comfort.
Check: In your own words, summarise the difference between a variable, a transition, and an animation.
Practical task
Build a small themeable card page: three cards, each with a title, a paragraph, and a button, styled with CSS variables declared in :root for colours, spacing, and radius. Add a theme toggle button that switches a dark class on the body, with a short transition on background and text colours so the switch cross-fades. Give each card a hover transition using only transform and opacity-related changes, and add a loading spinner that appears for two seconds when a card button is clicked, then shows a success message. Finally, add a prefers-reduced-motion rule that removes the decorative animations, and test the page by tabbing through it with a keyboard, by toggling the theme, and with reduced motion enabled, noting anything that fails.
Chapter questions
- Explain, in plain words, the difference between a CSS variable and a Sass variable, and why that difference matters for theming.
- Write the CSS for a button that darkens and lifts slightly on hover, with a 0.25-second ease-out transition, and explain why the transition belongs on the base rule.
- A menu fades out but remains focusable by keyboard when invisible. What is wrong, and how do you fix it?
- Why are
transformandopacitypreferred for animations overwidthandleft? - Describe how you would make a loading spinner accessible and respectful of reduced-motion preferences.