Skip to content

Create accessible web animations

You will add a completion control to a responsive card. The control updates visible text and program state immediately. When the user has not requested reduced motion, a short progress transition and one finite confirmation animation reinforce that state change.

Motion can show how an interface changed, but it must not carry information by itself. Some motion can also cause distraction, dizziness, nausea, or headaches. A robust interface defines the useful state first, keeps the interaction complete without motion, and treats animation as an optional presentation layer.

What you will practice

  • Describe the state change and its purpose before choosing an animation.
  • Distinguish a CSS property transition from a CSS keyframe sequence.
  • Connect one button event to JavaScript state, DOM text, ARIA state, and CSS state selectors.
  • Animate transform and opacity without hiding required information.
  • Use prefers-reduced-motion to add non-essential motion only for no-preference.
  • Avoid automatic, looping, flashing, and interaction-blocking animation in the required result.
  • Verify the same interface state with motion enabled and reduced.
  • New: Motion purpose, animation states, transition duration, timing functions, @keyframes, animation, transform, transform-origin, and prefers-reduced-motion.
  • Reused: The responsive component workspace, one boolean state value, buttons, aria-pressed, role="status", data attributes, event listeners, CSS custom properties, focus styles, media queries, keyboard testing, 320px checks, 200% zoom, DevTools, and Git checkpoints.

Starting point

Before you start

  • The completed responsive-components folder from the preceding lesson.
  • A verified copy of the narrow and wide resource-card result.
  • VS Code and a current version of Edge, Chrome, or Firefox.
  • Permission to add app.js and replace the practice page content.
  • No animation library, framework, image, video, or audio file is required.
Current state
The resource card adapts to its container, but it has no interactive completion state and no motion layer.
First action
Copy the completed responsive-components folder, rename the copy web-animation, and add an empty app.js beside index.html and styles.css.
First checkpoint
The web-animation folder contains index.html, styles.css, and app.js, and the copied page still opens without an error.
Help trigger
Use the recovery note or ask for help if the button changes no state, the status text and aria-pressed value disagree, animation starts on page load, motion still runs with reduced motion enabled, or keyboard focus becomes hidden.

You have completed the lesson when:

  • web-animation contains index.html, styles.css, and app.js;
  • one real button changes one boolean completion state;
  • the button’s aria-pressed value and visible label match that state;
  • a role="status" region reports 0 of 1 verification steps complete. or 1 of 1 verification steps complete.;
  • the card’s data-complete value gives CSS a stable state selector;
  • a progress fill reaches its correct start or end state even when transitions are unavailable;
  • one transform transition and one finite @keyframes confirmation reinforce the state change only when prefers-reduced-motion: no-preference matches;
  • reduced-motion mode changes the same text, pressed state, data value, and progress result without movement or delay;
  • the required result has no automatic, infinite, flashing, parallax, scroll-linked, or content-blocking animation;
  • meaning does not depend only on motion, position, shape, or color;
  • the control remains operable with Tab, Space, and Enter;
  • the page remains usable near 320px and at 200% browser zoom; and
  • the final page produces no JavaScript error in the Console.

Use one sentence to define what changes:

Activating the button changes the verification step between open and complete.

The interface must expose that change through several aligned values:

Interface value Open state Complete state
JavaScript state false true
Card data attribute data-complete="false" data-complete="true"
Button state aria-pressed="false" aria-pressed="true"
Button label Mark verification complete Mark verification as open
Status text 0 of 1 verification steps complete. 1 of 1 verification steps complete.
Progress fill Start of track End of track

Motion does not appear in this table because it does not define the state. It can reinforce the change after every required value works.

Replace index.html with this document:

web-animation/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Accessible completion animation</title>
<link rel="stylesheet" href="styles.css">
<script src="app.js" defer></script>
</head>
<body>
<main>
<section
class="completion-card"
id="completion-card"
data-complete="false"
aria-labelledby="completion-title"
>
<p class="completion-card__eyebrow">Verification step</p>
<h1 id="completion-title">Responsive component review</h1>
<p>
Confirm that the component works in narrow and wide containers.
</p>
<p class="completion-status" id="completion-status" role="status">
0 of 1 verification steps complete.
</p>
<div class="progress-track" aria-hidden="true">
<span class="progress-fill"></span>
</div>
<button
class="completion-button"
id="completion-button"
type="button"
aria-pressed="false"
aria-describedby="completion-status"
>
Mark verification complete
</button>
</section>
<section aria-labelledby="test-title">
<h2 id="test-title">What to verify</h2>
<ul>
<li>The visible status and button state agree.</li>
<li>The result stays clear without movement.</li>
<li>The button works with a pointer and keyboard.</li>
</ul>
</section>
</main>
</body>
</html>

The button changes interface state, so it is a button instead of a link. aria-pressed exposes its two-state toggle behavior. The visible label still states the available action.

The status region contains useful text before JavaScript runs. aria-describedby connects the button to that current result. The progress track uses aria-hidden="true" because it repeats the text state instead of adding new information.

Open the page before adding JavaScript or CSS. Confirm that:

  • one H1 names the completion card;
  • the status starts with 0 of 1 verification steps complete.;
  • Tab reaches the button;
  • Space and Enter activate the native button, although no state changes yet; and
  • the What to verify list remains visible.

Correct the HTML before continuing.

Add this code to app.js:

web-animation/app.js
"use strict";
const completionCard = document.querySelector("#completion-card");
const completionButton = document.querySelector("#completion-button");
const completionStatus = document.querySelector("#completion-status");
if (!(completionCard && completionButton && completionStatus)) {
throw new Error("The completion interface is missing a required element.");
}
let isComplete = false;
const renderCompletion = () => {
completionCard.dataset.complete = String(isComplete);
completionButton.setAttribute("aria-pressed", String(isComplete));
completionButton.textContent = isComplete
? "Mark verification as open"
: "Mark verification complete";
completionStatus.textContent = isComplete
? "1 of 1 verification steps complete."
: "0 of 1 verification steps complete.";
};
completionButton.addEventListener("click", () => {
isComplete = !isComplete;
renderCompletion();
});
renderCompletion();

The event handler changes one boolean state value and then renders every value that depends on it. CSS will read the same state through data-complete.

The initial renderCompletion() call synchronizes the document with isComplete. It does not simulate a click or start an animation. The initial state remains open.

Reload the page and run this sequence:

  1. Confirm the card starts with data-complete="false".
  2. Activate the button with a pointer. Confirm the data value, pressed state, label, and status all change to complete.
  3. Activate it again. Confirm every value returns to open.
  4. Repeat with Tab and Space, then with Enter.
  5. Reload. Confirm the documented open state returns and the Console contains no error.

Do not add animation until all five checks pass.

Checkpoint: The state works without motion

What now works
One button toggles one boolean value; the card data attribute, pressed state, visible label, and status text remain synchronized with pointer and keyboard activation.
Files changed
web-animation/index.html, web-animation/app.js
What remains
Style the static start and end states, then add motion only for users who have not requested less motion.
Next action
Add the base stylesheet and confirm that the progress fill changes instantly between its two complete states.
If it does not work
Log isComplete at the start of renderCompletion. Then inspect the card attribute, button attribute, button text, and status text in that order to find the first value that disagrees.

Replace styles.css with these base rules:

web-animation/styles.css — complete static states
:root {
--color-page: #f3f7f5;
--color-surface: #ffffff;
--color-text: #1d2b26;
--color-muted: #45564f;
--color-border: #687b72;
--color-primary: #176b4d;
--color-primary-strong: #0d4f38;
--color-primary-container: #dcefe7;
--color-track: #ccd8d2;
--color-focus: #c2410c;
--space-1: 0.5rem;
--space-2: 0.75rem;
--space-3: 1rem;
--space-4: 1.5rem;
--space-5: 2rem;
--radius: 0.5rem;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
color: var(--color-text);
background: var(--color-page);
font-family: system-ui, sans-serif;
line-height: 1.6;
}
main {
display: grid;
gap: var(--space-5);
inline-size: min(calc(100% - 2rem), 44rem);
margin-inline: auto;
padding-block: var(--space-5);
}
.completion-card {
display: grid;
gap: var(--space-3);
min-inline-size: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
padding: var(--space-4);
}
.completion-card__eyebrow,
.completion-card h1,
.completion-card > p {
margin-block: 0;
}
.completion-card__eyebrow {
color: var(--color-muted);
font-weight: 700;
}
.completion-card h1 {
line-height: 1.2;
}
.completion-status {
border-inline-start: 0.25rem solid var(--color-border);
background: var(--color-page);
padding: var(--space-2) var(--space-3);
font-weight: 700;
}
.progress-track {
block-size: 0.75rem;
border-radius: 999px;
background: var(--color-track);
overflow: hidden;
}
.progress-fill {
display: block;
block-size: 100%;
border-radius: inherit;
background: var(--color-primary);
transform: scaleX(0);
transform-origin: left center;
}
.completion-button {
justify-self: start;
min-block-size: 2.75rem;
max-inline-size: 100%;
border: 2px solid var(--color-primary);
border-radius: var(--radius);
background: var(--color-primary);
padding: var(--space-1) var(--space-3);
color: #ffffff;
font: inherit;
font-weight: 700;
overflow-wrap: anywhere;
cursor: pointer;
}
.completion-button:hover {
background: var(--color-primary-strong);
}
.completion-button:focus-visible {
outline: 3px solid var(--color-focus);
outline-offset: 3px;
}
.completion-card[data-complete="true"] .completion-status {
border-inline-start-color: var(--color-primary);
background: var(--color-primary-container);
}
.completion-card[data-complete="true"] .progress-fill {
transform: scaleX(1);
}

The progress fill already has a correct start and end state. Without a transition, transform changes immediately. The status text and aria-pressed state remain the primary evidence.

transform-origin: left center makes the scale change start at the inline-start side for this left-to-right practice document. The required interface does not use the fill direction as its only meaning.

Test the toggle again. Confirm that no content moves around the page and the button remains focused after every activation.

A transition creates intermediate values when a CSS property changes. A keyframe animation defines a named sequence that can contain selected points between its start and end.

Add this keyframe definition and media query at the end of styles.css:

web-animation/styles.css — optional motion layer
@keyframes status-confirmation {
from {
opacity: 0.65;
transform: translateY(0.25rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: no-preference) {
.progress-fill {
transition: transform 240ms ease-out;
}
.completion-card[data-complete="true"] .completion-status {
animation: status-confirmation 240ms ease-out 1;
}
}

The transition runs when the progress fill’s transform changes in either direction. The keyframe sequence runs only when the card enters the complete state. Its final 1 iteration count makes the sequence finite.

The media query is an opt-in motion boundary. The animation declarations apply only when the browser reports no-preference. When the user requests reduced motion, or when a browser does not match that condition, the static CSS states still change immediately.

Do not add a delay. The program changes the state and text as soon as the event occurs. The presentation layer does not postpone or block the result.

With motion allowed:

  1. Open the Animations or animation-inspection panel in DevTools if your browser provides one.
  2. Activate the button once.
  3. Confirm one 240ms progress transition and one 240ms status animation.
  4. Confirm the status animation ends after one iteration.
  5. Activate the button several times. Confirm rapid input never locks the control or makes the state values disagree.

The small vertical translation and progress fill reinforce the state change. They do not reveal hidden content, move focus, scroll the page, or require the user to wait.

Enable the operating system’s reduced-motion preference, or use the Rendering panel in browser DevTools to emulate prefers-reduced-motion: reduce. Reload the page after changing the setting.

Activate the button in both directions. Confirm that:

  • the card data value changes;
  • aria-pressed changes;
  • the visible button label and status text change;
  • the progress fill reaches the correct end state immediately;
  • the computed transition duration is 0s;
  • the computed animation name is none; and
  • the control remains focused and usable.

Reduced motion does not mean reduced information or reduced function. The state result is the same; only the non-essential intermediate movement is absent.

Checkpoint: Motion reinforces but does not own the result

What now works
The normal preference receives one short progress transition and one finite confirmation animation; reduced-motion mode receives the same complete state, text, pressed value, and progress result without movement or delay.
Files changed
web-animation/index.html, web-animation/styles.css, web-animation/app.js
What remains
Run the final interaction, responsive, repetition, and motion-safety matrix.
Next action
Test the open and complete states with the keyboard at a narrow viewport, then repeat the same path with reduced motion enabled.
If it does not work
Inspect whether the media query matches. Then inspect transition-property, transition-duration, animation-name, and animation-iteration-count on the exact element that should change.
Condition Expected result
Initial load Open text and state appear; no animation starts
Pointer activation All state values change once and motion ends
Space and Enter The native button toggles once per activation and keeps focus
Rapid repeated activation The final visual result matches the final boolean and pressed state
no-preference One 240ms transition and one finite complete-state animation can run
reduce State changes immediately with no transition or keyframe animation
CSS motion declarations disabled The complete start and end states still work
JavaScript unavailable The initial open state and verification content remain readable
Page near 320px Text and the button wrap without horizontal scrolling
Normal page view at 200% zoom Content reflows and the control remains visible and operable
Console No red JavaScript error appears

Do not test flashing thresholds by adding flashing content. The required result avoids flashing entirely.

Self-check

Complete these checks against the required result.

  1. State the useful interface change in one sentence without mentioning animation.
  2. Confirm that isComplete, data-complete, aria-pressed, the button label, and the status text agree in both states.
  3. Explain why the progress track is aria-hidden and where the equivalent text information appears.
  4. Disable the transition and animation declarations and confirm that the complete result still appears.
  5. Explain the difference between the progress transition and the status keyframe sequence.
  6. Confirm that the required animation has no automatic start, delay, infinite iteration, flashing, parallax, or scroll trigger.
  7. Emulate no-preference and confirm that the transition and one finite complete-state animation can run.
  8. Emulate reduce and confirm a 0s transition duration, no animation name, and unchanged interface function.
  9. Toggle rapidly with a pointer and keyboard and confirm that the final presentation matches the final state.
  10. Test near 320px and at 200% zoom and confirm that content, focus, and the button remain visible.
  11. Reload the final page and confirm that the initial state is documented and the Console contains no error.
Symptom Likely cause Focused check
Animation starts during page load The animated selector matches the initial state or JavaScript simulates a change Confirm the initial data value is false and no click is dispatched
Reduced motion still moves Motion declarations exist outside the no-preference query Search for every transition and animation assignment
Progress moves from its center transform-origin uses its default center point Inspect the transform origin on the fill
Motion runs forever The animation uses infinite or repeats by default project code Confirm the iteration count is 1
Text updates after the movement Program logic waits for a transition or animation event Render state immediately in the click handler
Button label and pressed state disagree The renderer updates only one dependent value Trace every assignment in renderCompletion
Focus disappears The click handler replaces or disables the button Keep the same native button element and its visible focus style
The page shifts during animation Layout properties such as width, height, or margin change Keep layout static and inspect the transformed descendant

Correct the state mismatch before tuning timing or easing.

After the required self-check passes, record only the verified files in the private web-animation repository:

Commit and push the accessible animation
git status
git diff
git add index.html styles.css app.js
git diff --staged
git commit -m "Add accessible completion animation"
git push

Confirm that the remote contains the static state rules, the preference-aware motion layer, and the state-render JavaScript.

The required lesson is complete when the completion state works without motion, the motion layer reinforces one user-triggered change, and reduced-motion mode receives the same information and function without movement or delay.

Continue to Practice: Build an animated responsive interface to apply the responsive-component and motion patterns to an independent product brief.

If you stop here, leave yourself this resume note: The completion state works with and without motion. Next, open the animated responsive interface assignment and define its required state change before choosing an effect.