Accessibility: Motion & Animation Safety
Accessibility: Motion & Animation Safety
As of v1.0.472, the MTD platform fully respects the OS "Reduce Motion" accessibility preference. This implements WCAG 2.3.3 (AAA) and 2.3.1 compliance across the entire application.
Who this affects
Users who have enabled the reduced-motion preference on their operating system:
| OS | Setting location |
|---|---|
| macOS / iOS | System Settings → Accessibility → Motion → Reduce Motion |
| Android | Display → Remove animations |
| Windows | Ease of Access → Display → Show animations in Windows |
When this preference is active, the browser exposes the prefers-reduced-motion: reduce media query, which the platform reads to suppress animations.
What is suppressed
When reduced motion is active, all animations and transitions are collapsed to 0.01ms (imperceptibly fast, effectively instant):
| Animation | Component(s) | Behaviour with reduced motion |
|---|---|---|
animate-ping (overdue dot) | Sidebar quarterly deadline badge, MTD compliance ribbon | Renders as a static coloured dot — no pulsing |
animate-pulse | Loading skeletons | No pulsing; skeleton appears as a static shape |
animate-spin | Loading spinners | Spinner appears static |
animate-in, fade-in-0, zoom-in-95 | Modals, dropdowns, notification bell | Elements appear instantly without entrance motion |
Visual information (colour, shape, text labels) is always preserved — only the motion is removed.
Implementation details
Global CSS safety net (globals.css)
A @media (prefers-reduced-motion: reduce) block targets every element on the page:
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
Why 0.01ms instead of 0?
Setting duration to exactly 0 suppresses animationend events in some browsers, which can break JavaScript listeners that rely on them (for example, tw-animate-css state transitions). 0.01ms is imperceptible to humans but still fires events correctly.
Why animation-iteration-count: 1?
Infinite-loop animations (animate-ping, animate-spin) would otherwise run exactly one imperceptibly-fast loop and then freeze in a mid-animation CSS state. Setting iteration count to 1 ensures they complete and rest in their natural end state.
Component-level gating (motion-safe: Tailwind variant)
The two overdue-quarter pulsing dots — which loop continuously whenever a submission deadline is missed — are additionally gated at the component level with Tailwind's motion-safe: variant:
// sidebar-nav.tsx — QuarterlyDeadlineBadge
<span className="absolute inline-flex h-full w-full motion-safe:animate-ping rounded-full bg-red-500 opacity-75" />
// mtd-compliance-ribbon.tsx — MtdComplianceRibbon
<span className={[
"absolute inline-flex h-full w-full motion-safe:animate-ping rounded-full opacity-75",
colours.dot,
].join(" ")} />
With motion-safe:animate-ping, the class is simply not applied when the user has reduced motion enabled — the element renders as a static dot with no animation CSS attached at all.
Defence-in-depth
Both layers are applied together intentionally:
┌─────────────────────────────────────────────────────────┐
│ Global CSS rule (globals.css) │
│ Collapses ALL animation/transition utilities app-wide │
│ ↳ Safety net for any component not explicitly gated │
├─────────────────────────────────────────────────────────┤
│ motion-safe:animate-ping (sidebar-nav, ribbon) │
│ Explicit opt-in on the two highest-impact animations │
│ ↳ Perpetually-looping overdue dots get precise control │
└─────────────────────────────────────────────────────────┘
WCAG compliance
| Criterion | Level | Description |
|---|---|---|
| 2.3.3 Animation from Interactions | AAA | Motion triggered by interaction can be disabled |
| 2.3.1 Three Flashes or Below Threshold | A | Content does not flash more than three times per second |
Adding new animated components
When adding animations to any new component, follow this pattern:
-
Perpetually looping animations (
animate-ping,animate-spinused as indefinite spinners): use themotion-safe:Tailwind variant explicitly.// ✅ Correct <span className="motion-safe:animate-ping" /> // ❌ Avoid for infinite loops <span className="animate-ping" /> -
Entrance / one-shot animations (
animate-in,fade-in-0, etc.): the global CSS safety net inglobals.csscovers these automatically, but addingmotion-safe:is also acceptable and encouraged for clarity. -
Do not use
animation-duration: 0to suppress motion — use0.01msto preserveanimationendevent firing.