DEV Community

Rizwan Saleem
Rizwan Saleem

Posted on

Building Accessible Web Animations with Motion Design Tokens

Building Accessible Web Animations with Motion Design Tokens

Building Accessible Web Animations with Motion Design Tokens

Creating engaging user interfaces often hinges on motion. But motion is easy to overdo or misapply, especially for users with accessibility needs. This tutorial shows how to design, implement, and maintain a robust system of motion design tokens that powers accessible, cohesive animations across a frontend app. You’ll get practical patterns, real code, and a step-by-step workflow you can adopt in any modern framework (React, Vue, Svelte, or vanilla JS).

Why motion tokens matter

  • Consistency: Centralized tokens ensure all components use the same timing, easing, and reverberation rules.
  • Accessibility: You can enforce reductions or reflows for users who need motion reduced or disabled.
  • Maintainability: Designers and developers speak a shared language; tweaks propagate automatically.
  • Performance: Tokens enable predictable animation paths that are friendlier to the browser’s compositor.

By the end, you’ll have a working tokens system, example components, and a reviewer checklist to keep motion accessible and productive.

Design foundations: what to encode in tokens

  • Duration categories: fast, medium, slow
  • Easing curves: standard ramps (ease-out, cubic-bezier values)
  • Delays and stagger: per-item delays for lists or grids
  • Motion states: initial, animate, exit
  • Reduced-motion overrides: supply alternative values when user prefers-reduced-motion
  • Motion intensity scale: how strong or subtle an animation should feel

Example token shapes:

  • duration: { quick: 150, medium: 300, slow: 600 }
  • easing: { inOut: 'cubic-bezier(.165,.84,.44,1)', out: 'cubic-bezier(.34,1.56,.64,1)' }
  • stagger: { children: 40, listItem: 20 }
  • reducedMotion: { duration: { quick: 0, medium: 0, slow: 0 }, opacity: 0 } ### Step 1: define the tokens in a typed, language-agnostic file

Choose a single source of truth. Use a schema that can be consumed by CSS, JS, and design tokens tooling.

  • Create a tokens.json or tokens.ts (TypeScript) for type safety.
  • Provide both a CSS custom properties surface and a JS export.

Example tokens.ts (TypeScript):

// tokens.ts
export type Easing = 'ease' | 'easeIn' | 'easeOut' | 'easeInOut' | string;

export interface MotionTokens {
  duration: {
    quick: number;   // ms
    medium: number;
    slow: number;
  };
  easing: {
    ease: Easing;
    easeIn: Easing;
    easeOut: Easing;
    easeInOut: Easing;
  };
  stagger: {
    small: number;
    medium: number;
    large: number;
  };
  reducedMotion: {
    duration: {
      quick: number;
      medium: number;
      slow: number;
    };
    opacity: number;
    translate: number;
  };
}

export const motionTokens: MotionTokens = {
  duration: { quick: 150, medium: 300, slow: 600 },
  easing: {
    ease: 'cubic-bezier(.25,.1,.25,1)',
    easeIn: 'cubic-bezier(.42,0,1,1)',
    easeOut: 'cubic-bezier(0,0,.58,1)',
    easeInOut: 'cubic-bezier(.42,0,.58,1)',
  },
  stagger: { small: 20, medium: 40, large: 80 },
  reducedMotion: {
    duration: { quick: 0, medium: 0, slow: 0 },
    opacity: 0,
    translate: 0,
  },
};
Enter fullscreen mode Exit fullscreen mode

Then expose CSS variables via a small runtime helper:

// tokens.css.js (or tokens.css with CSS variables)
export function applyMotionCssVariables(vars: MotionTokens) {
  const root = document.documentElement;
  root.style.setProperty('motion-duration-quick', `${vars.duration.quick}ms`);
  root.style.setProperty('motion-duration-medium', `${vars.duration.medium}ms`);
  root.style.setProperty('motion-duration-slow', `${vars.duration.slow}ms`);

  root.style.setProperty('motion-easing-ease', vars.easing.ease);
  root.style.setProperty('motion-easing-easeIn', vars.easing.easeIn);
  root.style.setProperty('motion-easing-easeOut', vars.easing.easeOut);
  root.style.setProperty('motion-easing-easeInOut', vars.easing.easeInOut);

  root.style.setProperty('motion-reduced-translate', `${vars.reducedMotion.translate}px`);
  root.style.setProperty('motion-reduced-opacity', `${vars.reducedMotion.opacity}`);
}
Enter fullscreen mode Exit fullscreen mode

Step 2: respect user preferences (prefers-reduced-motion)

  • Detect user preference with media query: (prefers-reduced-motion: reduce)
  • Use JavaScript to switch to reduced values or disable certain animations.
  • Provide a graceful degrade: you can animate opacity or layout minimally, not transform-heavy effects.

Implementation sketch:

function useReducedMotion() {
  const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
  const enabled = mediaQuery.matches;
  function update() {
    return mediaQuery.matches;
  }
  return { enabled, update };
}

function applyReducedMotionIfNeeded(tokens: MotionTokens) {
  const { enabled } = useReducedMotion();
  if (enabled) {
    // Override CSS vars or token usage
    document.documentElement.style.setProperty('motion-duration-medium', '0ms');
    document.documentElement.style.setProperty('motion-transition', 'none');
  }
}
Enter fullscreen mode Exit fullscreen mode

In CSS, you can also provide a class on a root container when reduced motion is on:

@media (prefers-reduced-motion: reduce) {
  :root { 
    motion-duration-quick: 0ms;
    motion-duration-medium: 0ms;
    motion-duration-slow: 0ms;
    motion-easing-ease: linear;
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 3: implement a reusable motion primitive

Create a small, composable motion utility that components can use to animate on mount, hover, and exit.

React example using CSS custom properties and a tiny hook:

import { motionTokens } from './tokens';
import { useMemo } from 'react';

function useEnterAnimation({ duration = 'medium', delay = 0 } = {}) {
  const dur = `var(motion-duration-${duration})`;
  const style = useMemo(() => ({
    animation: `fadeInUp ${dur} ${motionTokens.easing.easeInOut} ${delay}ms both`,
  }), [duration, delay]);
  return style;
}
Enter fullscreen mode Exit fullscreen mode

CSS:

@keyframes fadeInUp {
  from { opacity: 0; transform: translateY(8px); }
  to { opacity: 1; transform: translateY(0); }
}
Enter fullscreen mode Exit fullscreen mode

Usage:

import React from 'react';
import { useEnterAnimation } from './motion-helpers';

export function Card({ title, content }) {
  const style = useEnterAnimation({ duration: 'medium', delay: 0 });
  return (
    <section className="card" style={style}>
      <h3>{title}</h3>
      <p>{content}</p>
    </section>
  );
}
Enter fullscreen mode Exit fullscreen mode

If you prefer JavaScript-only or CSS-in-JS, adapt to your stack (Styled Components, Emotion, or Svelte/Vue equivalents). The principle remains: derive all animation timing and easing from tokens.

Step 4: motion guidelines for components

  • Always provide a prefers-reduced-motion override path.
  • Use motion for meaningful state changes (e.g., feedback on user actions, content loaded, or focus transitions).
  • Avoid animating layout-changing properties (width/height) unless you’re using a well-tested FLIP approach.
  • Prefer entrance and exit animations for modal dialogs, toasts, and list items, not constant, perpetual motion.
  • Keep durations user-friendly: quick (150-200ms) for small micro-interactions, medium (250-400ms) for content changes, slow (500-800ms) for larger transitions.

Example checklist for a component:

  • Is there a usage of transform/opacity rather than layout-affecting properties?
  • Is there a reduced-motion alternative?
  • Do delays, durations, and easings come from tokens?
  • Are there ARIA-live regions or announcements for dynamic content? ### Step 5: practical patterns and patterns-to-code examples

Pattern A: List item stagger on mount

  • Use per-item delay proportional to index.
  • Consume tokens.stagger.small or tokens.stagger.medium.

React pseudo-implementation:

function List({ items }) {
  return (
    <ul className="list" aria-label="Animated list">
      {items.map((it, idx) => (
        <li key={it.id} style={{
          animation: `slideIn ${getDur('medium')} ${getEase('easeOut')} ${idx * 40}ms both`
        }}>
          {it.label}
        </li>
      ))}
    </ul>
  );
}
Enter fullscreen mode Exit fullscreen mode

CSS:

@keyframes slideIn {
  from { opacity: 0; transform: translateY(12px); }
  to { opacity: 1; transform: translateY(0); }
}
Enter fullscreen mode Exit fullscreen mode

Pattern B: Focus ring smoothly appears

  • Animate focus outline or glow on focus; avoid heavy outlines for accessibility.
.button:focus-visible {
  outline: none;
  box-shadow: 0 0 0 2px color-mq(focus, #3b82f6);
  transition: box-shadow var(motion-duration-short, 150ms) var(motion-easing-easeOut);
}
Enter fullscreen mode Exit fullscreen mode

Pattern C: Card reveal on section load with FLIP-style feel

  • Use a simple fade + translate; manage stagger via container.
<div className="cards" style={{ display: 'grid', gap: 'var(gap, 1rem)' }}>
  {cards.map((c, i) => (
    <article key={c.id} className="card" style={getCardRevealStyle(i)}>
      <h4>{c.title}</h4>
      <p>{c.summary}</p>
    </article>
  ))}
</div>
Enter fullscreen mode Exit fullscreen mode
function getCardRevealStyle(i) {
  const delay = i * 40;
  return {
    animation: `fadeUp ${getDur('medium')}ms var(motion-easing-easeOut) ${delay}ms both`
  };
}
Enter fullscreen mode Exit fullscreen mode

Step 6: testing motion tokens

  • Visual regression: take screenshots of components with animations off and on to ensure no drift.
  • Accessibility: verify motion-reduced settings trigger and do not cause content to become inaccessible.
  • Performance: measure frame rates during animations; avoid heavy paint on long lists.

Practical tests:

  • Simulate reduced motion in your test harness and ensure timeouts/animations are 0-no-jank.
  • Use lightweight FPS probes in a few pages to confirm consistency.

Example test through a lightweight Jest + Puppeteer setup:

test('animations respect reduced motion', async () => {
  await page.goto('http://localhost:3000');
  // Enable reduced motion
  await page.addStyleTag({ content: '@media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } }' });
  // Trigger a motion
  await page.click('#open-modal');
  const hasAnimation = await page.evaluate(() => {
    const el = document.querySelector('#modal');
    return getComputedStyle(el).animationDuration !== '0s';
  });
  expect(hasAnimation).toBe(false);
});
Enter fullscreen mode Exit fullscreen mode

Step 7: workflow integration and team practices

  • Create a motion tokens file in your design system repository.
  • Add a lint rule or a CI check that ensures new animations reference tokens rather than hard-coded values.
  • Document decisions: why certain durations and easings were chosen; link to accessibility rationale.
  • Review process: require a quick motion review for new components that animate.

Workflow example:

  • Designers export tokens from a design tool (Figma/Abstract) into tokens.json.
  • Developers wire tokens.ts to the design tokens, ensuring consistency.
  • A cross-functional motion review includes accessibility, performance, and maintainability checks.

    Step 8: real-world starter kit

  • Tokens file: tokens.ts exporting motionTokens with durations, easings, and reducedMotion overrides.

  • A small helper library: motion-helpers.tsx that provides:

    • useEnterAnimation
    • useExitAnimation
    • animationStyleFromToken
  • CSS primitives: a couple of keyframes (fadeInUp, fadeOut) and a CSS variable surface to theme.

Starter snippet:

// motion-helpers.ts
import { motionTokens } from './tokens';

export function getDur(key: keyof typeof motionTokens.duration) {
  return `var(motion-duration-${key})`;
}
export function getEase(key: keyof typeof motionTokens.easing) {
  return `var(motion-easing-${key})`;
}
Enter fullscreen mode Exit fullscreen mode
@keyframes fadeInUp {
  from { opacity: 0; transform: translateY(8px); }
  to { opacity: 1; transform: translateY(0); }
}
Enter fullscreen mode Exit fullscreen mode

Step 9: example end-to-end page

Imagine a dashboard page where panels slide in, items in a grid stagger, and a modal uses a refined entrance.

  • On mount, panels animate with fadeInUp and a medium duration.
  • List items in panels stagger with small delays.
  • Focus states are clearly visible but subtle.
  • If the user prefers reduced motion, all panels reduce to a static appearance, no motion.

Code sketch (React-like):

export function Dashboard({ panels }) {
  return (
    <main className="dashboard" aria-label="Dashboard with animated panels">
      {panels.map((p, idx) => (
        <section key={p.id} className="panel" style={{
          animation: `fadeInUp ${getDur('medium')} ${getEase('easeOut')} ${idx * 40}ms both`
        }}>
          <h2>{p.title}</h2>
          <div className="panel-content" style={{
            animation: `fadeInUp ${getDur('fast')} ${getEase('easeInOut')} ${idx * 20}ms 1 both`
          }}>
            {p.items.map((it, i) => (
              <div key={i} className="panel-item" style={{
                animation: `fadeInUp ${getDur('short' as any)} ${getEase('easeOut')} ${i * 20}ms both`
              }}>
                {it}
              </div>
            ))}
          </div>
        </section>
      ))}
      <ModalExample />
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

Note: adapt durations and keys to your token surface; this is a conceptual pattern, not a drop-in snippet.

Quick reference: a practical checklist

  • [ ] Tokens cover duration, easing, stagger, and reduced motion.
  • [ ] All animations derive from the tokens, not hard-coded values.
  • [ ] Reduced motion is respected globally and in components.
  • [ ] Animations are meaningful, not gratuitous; focus on state changes and feedback.
  • [ ] Accessibility considerations are included (focus states, ARIA as needed).
  • [ ] A reviewer path exists for motion decisions and performance. If you’d like, I can tailor this to your stack (React, Vue, Svelte, or vanilla) and generate a ready-to-run starter repository with a minimal design system and a sample page. Would you prefer React, Vue, or a framework-agnostic approach?

-

Rizwan Saleem | https://rizwansaleem.co

Sources

Top comments (0)