Color Mode

Animation: Properties, Orchestration, and Accessibility

CSS animations have eight individual properties, but most developers only ever use the shorthand with two or three values. That leaves a lot of control on the table. Understanding what each property does independently gives you the ability to sequence animations without JavaScript, create staggered effects with a single keyframe block, and build orchestrated multi-step sequences entirely in CSS. Add a solid understanding of prefers-reduced-motion, and you have everything you need to build animation systems that are both expressive and accessible.

Animation Properties

Every CSS animation is controlled by eight individual properties. The shorthand animation sets them all at once, but knowing each property separately lets you override one value without rewriting the entire shorthand, apply different timing to the same keyframe block, and layer multiple animations cleanly.

animation-name and animation-duration

animation-name references a @keyframes block by name. animation-duration sets how long one cycle takes. These two are the minimum required to produce a CSS animation. All other properties have defaults that produce a reasonable result.


        @keyframes slide-in {
            from { transform: translateX(-100%); }
            to { transform: translateX(0); }
        }

        .panel {
            animation-name: slide-in;
            animation-duration: 400ms;
        }
    

Prefer milliseconds (ms) over seconds (s) for durations under one second. It is easier to reason about 300ms than 0.3s when tuning animation timing.

animation-timing-function

The timing function controls how the animation progresses across its duration. It does not change how long the animation takes, only the acceleration curve within that time. The keyword values map to common easing curves:

For precise control, use cubic-bezier(x1, y1, x2, y2) to define a custom curve. Browser DevTools include a visual cubic-bezier editor. The linear() function, available in all modern browsers, lets you define a custom easing curve as a series of points, which is how spring-physics easing is implemented in CSS without JavaScript.

ease
ease-in
ease-out
ease-in-out
linear

animation-delay

animation-delay sets how long the browser waits before starting the animation. Positive values create a pause before playback begins. Negative values are a powerful and underused feature: they start the animation as if it were already partway through.


        /* Starts the animation 200ms into its cycle immediately on load */
        .spinner {
            animation: rotate 1s linear infinite;
            animation-delay: -200ms;
        }
    

Negative delay is the foundation of CSS staggered animations. By giving each item in a list a progressively more negative delay, you can make all items animate simultaneously but at different phases, creating the appearance of a wave or cascade without any JavaScript.

animation-iteration-count

Controls how many times the animation cycle repeats. Accepts a number or the keyword infinite. Fractional values are valid: animation-iteration-count: 1.5 plays one complete cycle and then half of another, stopping at the midpoint of the keyframe definition.

animation-direction

Controls whether the animation plays forward, backward, or alternates. The four values are:

alternate is particularly useful for looping animations like pulsing indicators or breathing effects. Instead of defining keyframes that return to the start value at 100%, you define the midpoint at 100% and let alternate handle the return trip.

animation-fill-mode

This property determines what styles the element carries before the animation starts (during a positive delay) and after the animation ends. It is one of the most commonly misunderstood animation properties.


        /* Without fill-mode: both, the element flashes at opacity: 1 during the delay,
           then jumps to opacity: 0 when the animation starts */
        .toast {
            animation: fade-in 300ms ease-out 500ms both;
        }
    
none
forwards
backwards
both

Watch the 800ms delay carefully. The none and forwards boxes stay fully visible at opacity 1 during the delay, then jump to opacity 0 when the animation begins. The backwards and both boxes immediately adopt the first keyframe's opacity 0, so there is no flash. After the animation completes, only forwards and both hold their final position.

animation-play-state

Toggles between running and paused. This is the CSS-only way to pause and resume animations, and it integrates cleanly with JavaScript because you can toggle a class rather than manipulating inline styles.


        .carousel {
            animation: slide 4s linear infinite;
        }

        .carousel.paused {
            animation-play-state: paused;
        }
    

        document.querySelector('.carousel').addEventListener('mouseenter', (e) => {
            e.currentTarget.classList.add('paused');
        });
    

The animation Shorthand

The shorthand packs all eight properties into one declaration. The order is flexible with one constraint: if you include both a duration and a delay, duration must come first.


        /* name | duration | easing | delay | iterations | direction | fill-mode | play-state */
        .element {
            animation: slide-in 400ms ease-out 100ms 1 normal both running;
        }

        /* In practice, you usually omit the defaults */
        .element {
            animation: slide-in 400ms ease-out 100ms both;
        }
    

Multiple Animations

A single element can run multiple animations simultaneously by separating them with commas. Each animation is independent and can have different keyframes, durations, and easing. The properties are applied to the same element and composited together.


        .notification {
            /* Fade in, and simultaneously slide up */
            animation:
                fade-in 300ms ease-out both,
                slide-up 300ms ease-out both;
        }
    

When two animations target the same property, the later declaration in the comma-separated list wins. This means you can use multiple animations to build up complex effects from simple, reusable keyframe blocks rather than writing monolithic keyframes for every combination.

Orchestration Patterns

Orchestration is the coordination of multiple animations across multiple elements so that they feel intentional and related rather than simultaneous and chaotic. There are two fundamental patterns: staggered animations, where the same animation runs on multiple elements with offset start times, and sequential animations, where different animations run one after another on the same element.

CSS Custom Properties for Stagger

The cleanest way to implement staggered animations in CSS is to use a custom property to hold each element's index and reference it in a calc() expression for the delay. This keeps the timing logic in CSS and the index assignment either in HTML or JavaScript.


        @keyframes fade-up {
            from {
                opacity: 0;
                transform: translateY(1.5rem);
            }
            to {
                opacity: 1;
                transform: translateY(0);
            }
        }

        .card {
            animation: fade-up 400ms ease-out both;
            animation-delay: calc(var(--stagger-index, 0) * 80ms);
        }
    

        <ul class="card-grid">
            <li class="card" style="--stagger-index: 0">...</li>
            <li class="card" style="--stagger-index: 1">...</li>
            <li class="card" style="--stagger-index: 2">...</li>
        </ul>
    

The default value var(--stagger-index, 0) means any card without the property still animates correctly at zero delay. The multiplier (80ms here) controls the gap between each item's animation start. Keep stagger delays short for lists of items: 50 to 100ms per item feels intentional. Longer delays make the sequence feel slow and tedious, especially for lists with many items.

If the HTML is dynamic, you can set the custom property from JavaScript with a simple loop rather than hardcoding it in markup.


        document.querySelectorAll('.card').forEach((card, index) => {
            card.style.setProperty('--stagger-index', index);
        });
    
80ms
1
2
3
4
5
6

Sequential Animations with animation-delay

To make one animation start exactly when another ends, calculate the delay as the sum of the preceding animation's duration and any of its own delay. This is straightforward to manage with custom properties.


        :root {
            --step-1-duration: 300ms;
            --step-2-duration: 400ms;
            --step-3-duration: 250ms;
        }

        .icon {
            animation: pop-in var(--step-1-duration) ease-out both;
        }

        .title {
            animation: fade-in var(--step-2-duration) ease-out both;
            animation-delay: var(--step-1-duration);
        }

        .subtitle {
            animation: fade-in var(--step-3-duration) ease-out both;
            animation-delay: calc(var(--step-1-duration) + var(--step-2-duration));
        }
    

Centralizing durations in custom properties at the :root level means changing a timing value only requires one edit. Without this, adjusting the duration of an early step requires manually recalculating every subsequent delay, which is error-prone and difficult to maintain.

Chaining with animation-iteration-count and animation-direction

For looping sequences, animation-direction: alternate avoids having to define return-trip keyframes. Combined with animation-iteration-count: infinite, this creates smooth, perpetual back-and-forth animations from a minimal keyframe definition.


        @keyframes breathe {
            from { transform: scale(1); }
            to { transform: scale(1.05); }
        }

        .pulse-indicator {
            animation: breathe 2s ease-in-out infinite alternate;
        }
    

Multi-Step Keyframes for Complex Single-Element Sequences

When one element needs to perform a sequence of distinct states, use percentage-based keyframes instead of stacking multiple animation declarations. Percentage positions in a @keyframes block map to exact points in the animation's total duration, letting you hold a state for a period before transitioning to the next.


        @keyframes notification-lifecycle {
            0%   { opacity: 0; transform: translateY(-1rem); }
            10%  { opacity: 1; transform: translateY(0); }
            80%  { opacity: 1; transform: translateY(0); }
            100% { opacity: 0; transform: translateY(-1rem); }
        }

        .toast {
            animation: notification-lifecycle 3s ease both;
        }
    

Here the toast spends its first 10% of the total duration animating in, holds its visible state from 10% to 80% (which is 2.1 seconds of a 3-second animation), and then animates out over the final 20%. This is more maintainable than coordinating multiple separate animations with calculated delays.

Changes saved successfully

JavaScript Sequencing with the Web Animations API

CSS-only sequencing works well when timing is known at authoring time, but some sequences depend on runtime conditions: an animation that should not start until a fetch resolves, or a chain where each step reacts to the result of the previous one. The Web Animations API's finished promise handles this cleanly.


        /**
         * Runs a multi-step entrance sequence on a hero section.
         * @param {HTMLElement} hero - The hero container element
         */
        const runHeroSequence = async (hero) => {
            const heading = hero.querySelector('h1');
            const subtext = hero.querySelector('p');
            const cta = hero.querySelector('.cta');

            const enterOptions = { duration: 400, easing: 'ease-out', fill: 'forwards' };
            const enterFrames = [
                { opacity: 0, transform: 'translateY(1rem)' },
                { opacity: 1, transform: 'translateY(0)' }
            ];

            await heading.animate(enterFrames, enterOptions).finished;
            await subtext.animate(enterFrames, enterOptions).finished;
            cta.animate(enterFrames, enterOptions);
        };
    

Each await pauses execution until that animation resolves, then the next one begins. The final animation is not awaited because there is nothing left to sequence after it. This pattern is easier to read and modify than a chain of setTimeout calls with calculated delays.

Accessibility and prefers-reduced-motion

Motion sensitivity is a real accessibility concern. Vestibular disorders, epilepsy, and other conditions mean that large-scale motion, parallax effects, and rapidly flashing animations can cause physical discomfort or harm. The prefers-reduced-motion media query gives users a way to communicate their preference to the browser, and it is your responsibility to respect it.

What prefers-reduced-motion Means

This media query reflects the operating system's "reduce motion" setting. When a user enables it, prefers-reduced-motion: reduce matches. The key word is "reduce," not "eliminate." The goal is not to strip all animation from the experience but to remove motion that is likely to cause discomfort: large translations, parallax scrolling, autoplaying videos, and rapid flashing. Subtle opacity fades and color transitions are generally safe.

The Safe Default Pattern

The most robust pattern is to write your reduced-motion styles as the default and enhance them inside a prefers-reduced-motion: no-preference query. This ensures users who have enabled reduce motion get the safe experience even if your CSS loads partially or a browser does not support the media query.


        /* Default: always safe, always accessible */
        .card {
            opacity: 0;
            transition: opacity 300ms ease;
        }

        .card.visible {
            opacity: 1;
        }

        /* Enhancement: full motion for users who have not requested reduced motion */
        @media (prefers-reduced-motion: no-preference) {
            .card {
                transform: translateY(2rem);
                transition: opacity 300ms ease, transform 400ms ease-out;
            }

            .card.visible {
                transform: translateY(0);
            }
        }
    

This approach gives motion-sensitive users a clean, functional fade-in while giving other users the full slide-and-fade entrance.

The Override Pattern

The more common (but slightly less safe) pattern writes motion-first and overrides it inside a prefers-reduced-motion: reduce query. This is acceptable when you are retrofitting existing code and writing motion-first is the path of least resistance.


        .card {
            animation: fade-up 400ms ease-out both;
        }

        @media (prefers-reduced-motion: reduce) {
            .card {
                animation: fade-in 300ms ease both;
            }
        }
    

Notice this does not set animation: none. It replaces the motion-heavy animation with a gentle fade, maintaining a sense of polish for all users rather than producing a jarring instant appearance for motion-sensitive users. The choice between none and a substitute animation depends on context: essential UI feedback should always have some animation, while purely decorative motion can be removed entirely.

Card One

Full motion: fades and slides up.

Card Two

Reduced motion: opacity fade only.

Card Three

Both modes use the same stagger timing.

Handling Scroll-Driven Animations

Scroll-driven animations require extra attention because they combine animation with continuous user motion. Even a subtle scroll-linked effect can be uncomfortable for motion-sensitive users. The safest approach is to not apply scroll-driven animations at all when reduced motion is preferred.


        @supports (animation-timeline: scroll()) {
            @media (prefers-reduced-motion: no-preference) {
                .section {
                    animation: fade-up linear both;
                    animation-timeline: view();
                    animation-range: entry 0% entry 40%;
                }
            }
        }
    

Nesting the @supports check inside the motion preference query (or vice versa) means the scroll-driven animation only applies when both conditions are met: the browser supports the API, and the user has not requested reduced motion.

JavaScript and prefers-reduced-motion

When animations are triggered or controlled by JavaScript, you need to check the media query programmatically. The matchMedia API provides this.


        const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');

        /**
         * Animates an element in, respecting the user's motion preference.
         * @param {HTMLElement} el - The element to animate
         */
        const animateIn = (el) => {
            if (reducedMotion.matches) {
                // Reduced motion: opacity fade only
                el.animate(
                    [{ opacity: 0 }, { opacity: 1 }],
                    { duration: 200, fill: 'forwards' }
                );
                return;
            }

            // Full motion: slide and fade
            el.animate(
                [
                    { opacity: 0, transform: 'translateY(1.5rem)' },
                    { opacity: 1, transform: 'translateY(0)' }
                ],
                { duration: 400, easing: 'ease-out', fill: 'forwards' }
            );
        };
    

You can also listen for changes to the preference in real time using reducedMotion.addEventListener('change', handler). This is important for long-running experiences where the user might toggle the system preference while the page is open, though this is an edge case you only need to handle in applications where continuous animations are running.

What Counts as Problematic Motion

Not all animations need to be modified for reduced motion. WCAG 2.1 Success Criterion 2.3.3 (AAA) provides guidance, but even the AA-level criterion 2.3.1 (no flashing more than three times per second) applies to all users. In practice, these categories of motion most often cause problems:

These categories are generally safe for all users and do not need to be removed under reduced motion:

Testing Motion Preferences

You do not need to change your system settings to test prefers-reduced-motion. Chrome and Firefox DevTools both allow you to emulate the media feature. In Chrome, open DevTools, go to the Rendering panel (found under the three-dot menu), and locate the "Emulate CSS media feature prefers-reduced-motion" dropdown. In Firefox, open the DevTools Responsive Design Mode and find the same option in the media queries panel. Test both states every time you write animation code.

Do Not Test Only on Your Own Machine

If you have not enabled reduced motion on your own system, your CSS default styles may never be tested. Always use DevTools to emulate the reduced motion preference when building animated interfaces, even when the feature is not the focus of your current work. It is much easier to handle during development than after the fact.

Key Concepts Summary

The eight animation properties give you precise control over every aspect of playback. animation-fill-mode: both is the safe default for entrance animations, preventing flashes during delays and holding the end state after completion. animation-delay with negative values is the foundation of CSS staggering, and centralizing durations in custom properties makes multi-step sequences maintainable when timing needs to change.

Staggered animations are most cleanly implemented with a --stagger-index custom property and a calc() delay. Sequential animations are handled in CSS by chaining delays equal to the sum of preceding durations, or in JavaScript by awaiting the finished promise from the Web Animations API when runtime conditions affect timing.

The safe default pattern for prefers-reduced-motion writes minimal, motion-free styles first and enhances inside (prefers-reduced-motion: no-preference). Scroll-driven animations should only be applied inside this same media query. In JavaScript, use window.matchMedia('(prefers-reduced-motion: reduce)') to check the preference before triggering motion-heavy effects. The goal is always to provide a functional, polished experience for all users, not to simply toggle animation on or off.