Create accessible web animations
Outcome
Section titled “Outcome”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.
Why this matters
Section titled “Why this matters”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.
What is new and what is reused
Section titled “What is new and what is reused”- New: Motion purpose, animation states, transition duration, timing functions,
@keyframes,animation,transform,transform-origin, andprefers-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,320pxchecks,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.
Required result
Section titled “Required result”You have completed the lesson when:
web-animationcontainsindex.html,styles.css, andapp.js;- one real button changes one boolean completion state;
- the button’s
aria-pressedvalue 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-completevalue gives CSS a stable state selector; - a progress fill reaches its correct start or end state even when transitions are unavailable;
- one
transformtransition and one finite@keyframesconfirmation reinforce the state change only whenprefers-reduced-motion: no-preferencematches; - 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
320pxand at200%browser zoom; and - the final page produces no JavaScript error in the Console.
Define the state before the animation
Section titled “Define the state before the animation”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.
Build the complete interface
Section titled “Build the complete interface”Replace index.html with this document:
<!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.
Check the unstyled interaction structure
Section titled “Check the unstyled interaction structure”Open the page before adding JavaScript or CSS. Confirm that:
- one
H1names 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.
Render one completion state
Section titled “Render one completion state”Add this code to 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.
Test the behavior without animation
Section titled “Test the behavior without animation”Reload the page and run this sequence:
- Confirm the card starts with
data-complete="false". - Activate the button with a pointer. Confirm the data value, pressed state, label, and status all change to complete.
- Activate it again. Confirm every value returns to open.
- Repeat with Tab and Space, then with Enter.
- 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.
Style both static states
Section titled “Style both static states”Replace styles.css with these base rules:
: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.
Add motion as a preference-aware layer
Section titled “Add motion as a preference-aware layer”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:
@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.
Inspect the animation contract
Section titled “Inspect the animation contract”With motion allowed:
- Open the Animations or animation-inspection panel in DevTools if your browser provides one.
- Activate the button once.
- Confirm one
240msprogress transition and one240msstatus animation. - Confirm the status animation ends after one iteration.
- 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.
Test reduced motion
Section titled “Test reduced motion”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-pressedchanges;- 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.
Run the final verification matrix
Section titled “Run the final verification matrix”| 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.
- State the useful interface change in one sentence without mentioning animation.
- Confirm that isComplete, data-complete, aria-pressed, the button label, and the status text agree in both states.
- Explain why the progress track is aria-hidden and where the equivalent text information appears.
- Disable the transition and animation declarations and confirm that the complete result still appears.
- Explain the difference between the progress transition and the status keyframe sequence.
- Confirm that the required animation has no automatic start, delay, infinite iteration, flashing, parallax, or scroll trigger.
- Emulate no-preference and confirm that the transition and one finite complete-state animation can run.
- Emulate reduce and confirm a 0s transition duration, no animation name, and unchanged interface function.
- Toggle rapidly with a pointer and keyboard and confirm that the final presentation matches the final state.
- Test near 320px and at 200% zoom and confirm that content, focus, and the button remain visible.
- Reload the final page and confirm that the initial state is documented and the Console contains no error.
Common animation failures
Section titled “Common animation failures”| 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.
Optional reference
Section titled “Optional reference”- Media Queries Level 5 defines the
prefers-reduced-motionuser-preference media feature. - CSS Transitions defines how property changes can produce transitions over a duration.
- CSS Animations defines keyframes, durations, timing functions, and iteration counts.
- WCAG 2.2: Animation from Interactions explains why non-essential interaction-triggered motion needs a way to be disabled.
- WCAG 2.2: Pause, Stop, Hide explains controls for automatically moving or updating content.
- WCAG 2.2: Three Flashes or Below Threshold explains the risk and thresholds for flashing content. The required result avoids flashes.
Record the animation checkpoint
Section titled “Record the animation checkpoint”After the required self-check passes, record only the verified files in the private web-animation repository:
git statusgit diffgit add index.html styles.css app.jsgit diff --stagedgit commit -m "Add accessible completion animation"git pushConfirm that the remote contains the static state rules, the preference-aware motion layer, and the state-render JavaScript.
Next step or safe stopping point
Section titled “Next step or safe stopping point”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.