Speed1×
Slow motion multiplier
Reduced motion
Show
Reduced motion
Criterion 10 — BlockerRail: OffThe sweep’s worklist
40 of 88 animated files have noprefers-reduced-motion branch at all — measured against the installed package. The brief says 39; the gap is counting method rather than a disagreement worth chasing, but the list below is the one to work from because it names the files.| Component | Files | Paths |
|---|---|---|
| Table | 6 | core/Table/TableRow.tsx · core/Table/plugins/columnResize/useTableColumnResize.tsx · core/Table/plugins/groupedRows/useTableGroupedRows.tsx · core/Table/plugins/rowExpansion/useTableRowExpansion.tsx · core/Table/plugins/stickyColumns/useTableStickyColumns.tsx · core/Table/plugins/tree/useTableTreeData.tsx |
| TopNav | 4 | core/TopNav/TopNavItem.tsx · core/TopNav/TopNavMegaMenu.tsx · core/TopNav/TopNavMegaMenuItem.tsx · core/TopNav/TopNavMenu.tsx |
| Chat | 2 | core/Chat/ChatComposer.tsx · core/Chat/ChatComposerDrawer.tsx |
| DropdownMenu | 2 | core/DropdownMenu/DropdownMenu.tsx · core/DropdownMenu/DropdownMenuSubMenu.tsx |
| TabList | 2 | core/TabList/Tab.tsx · core/TabList/TabMenu.tsx |
| (shared) | 1 | core/hooks/useKeyboardHint.tsx |
| Chart | 1 | lab/Chart/ChartAxis.tsx |
| ChatReasoning | 1 | lab/ChatReasoning/ChatReasoning.tsx |
| Citation | 1 | core/Citation/Citation.tsx |
| ClickableCard | 1 | core/ClickableCard/ClickableCard.tsx |
| Collapsible | 1 | core/Collapsible/Collapsible.tsx |
| CommandPalette | 1 | core/CommandPalette/CommandPaletteInput.tsx |
| ComplexSelector | 1 | core/ComplexSelector/ComplexSelector.tsx |
| ContextMenu | 1 | core/ContextMenu/ContextMenu.tsx |
| DateInput | 1 | core/DateInput/MonthScroller.tsx |
| InfoTip | 1 | lab/InfoTip/InfoTip.tsx |
| Item | 1 | core/Item/Item.tsx |
| Link | 1 | core/Link/Link.tsx |
| MultiSelector | 1 | core/MultiSelector/MultiSelector.tsx |
| Outline | 1 | core/Outline/Outline.tsx |
| Pagination | 1 | core/Pagination/Pagination.tsx |
| Resizable | 1 | core/Resizable/ResizeHandle.tsx |
| SegmentedControl | 1 | core/SegmentedControl/SegmentedControlItem.tsx |
| SelectableCard | 1 | core/SelectableCard/SelectableCard.tsx |
| Selector | 1 | core/Selector/Selector.tsx |
| Thumbnail | 1 | core/Thumbnail/Thumbnail.tsx |
| Token | 1 | core/Token/Token.tsx |
| Tour | 1 | lab/Tour/TourStep.tsx |
| TreeList | 1 | core/TreeList/TreeListItem.tsx |
Degrade or delete
| Policy | What survives | For | Against |
|---|---|---|---|
DeleteWhat the published page instructs today, and what most of core does. | Nothing. Transitions collapse to zero, loops stop, the end state arrives on the same frame as the change. | Safest reading of the accessibility guidance: a user who asked for no motion gets no motion, with no argument about which motion was the harmful kind. Trivially lintable, and it is one line per file. | Deletes feedback that happens to be implemented as a transition. A hover tint, a focus ring fade and a spinner all disappear together, and the spinner leaves nothing behind that says "working". |
DegradeWhat rubric criterion 10 proposes. A change of published policy, not a bug fix. | Opacity and colour. Movement multipliers go to zero; loops stop and are replaced with a determinate state. | Keeps state feedback while removing the vestibular trigger, which is what both of the project’s references recommend. The thing that hurts is movement, not the fact that a colour took 175ms to arrive. | Needs a judgement per component about which half is movement, so it is not a one-line sweep. Gets it wrong quietly: a fade that survives on a surface that also travels is still motion. |
This is a change of published policy, not a bug fixReversal
“When it’s enabled, replace animations with instant state changes.” — Respecting User Preferences, published today
Deleting motion is not an oversight in the components either — it is what the page instructs. The open question in the brief already asks degrade-or-delete; what the brief does not say is that "delete" is the currently published answer, so degrading is a change of policy rather than a fix.Needs a designer and accessibility sign-off, then a rewrite of this paragraph. The 39-file sweep writes whichever branch is chosen 39 times, so it cannot start first.The conflicts, side by side · the published pageThe three policies, running
Today — loops slow to 3s
Today the loop slows to 3s rather than stopping. Slower vestibular motion is still vestibular motion, and it reads as a hung interface.
Delete — the published answer
Delete: every loop stops. What the published page instructs — and a spinner that does not spin says nothing at all unless it is replaced with a determinate state.
Degrade — the proposed answer
Degrade: loops stop and are replaced with a static determinate state. Opacity and colour feedback survive; movement does not.
Slowing a loop is the one answer nobody is arguing for and the one the code does today. Between the other two, the question is not whether the spinner keeps spinning — it does not, either way — but whether anything replaces it.
Hover the rows, then flip the rail
Hardcoded 150ms
Row one
Row two
Row three
Hardcoded 150ms with the CSS default curve.Tokenised --duration-state
Row one
Row two
Row three
The state token, at 175ms with --ease-state.Set the rail to Degrade and hover: movement stops across the lab and these rows keep their fade. Colour is not movement, and this tint is exactly the feedback degrade exists to preserve — which is why delete is defensible on a hover tint and indefensible on a spinner, where the motion is the message.The left pane makes the token argument at the same time: it hardcodes 150ms, so it fades identically under every policy. A literal cannot be reached by a switch, a media query or a sweep.Try it: on Degrade the right pane keeps its tint and loses only movement — the state feedback survives. On Delete the tint snaps, and the affordance goes with it. That is the whole decision, in one hover. (This behaviour was itself a bug in the lab until the policy was moved out of the stylesheet: the store writes the duration aliases inline, and an inline custom property outranks any selector, so the delete rule looked right and did nothing.)
The 0.01s finding
13 of the 58 hardcoded values are 0.01s, and every one is a reduced-motion escape in BottomSheet, DateInput or MobileNav. This is load-bearing, not sloppiness: a zero duration fires no transitionend, so anything sequencing off that event — an unmount, a focus return, a scroll lock release — never runs. The idiom is a component author working around the platform.| Site | Component | Property |
|---|---|---|
| core/BottomSheet/BottomSheet.tsx:94 | BottomSheet | transitionDuration |
| core/BottomSheet/BottomSheetPanel.tsx:149 | BottomSheet | transitionDuration |
| core/BottomSheet/BottomSheetSwitcher.tsx:105 | BottomSheet | transitionDuration |
| core/DateInput/TouchDateField.tsx:256 | DateInput | transitionDuration |
| core/DateInput/TouchDateField.tsx:313 | DateInput | transitionDuration |
| core/DateInput/TouchDateField.tsx:375 | DateInput | transitionDuration |
| core/DateInput/TouchDateField.tsx:392 | DateInput | transitionDuration |
| core/DateInput/TouchDateField.tsx:442 | DateInput | transitionDuration |
| core/DateInput/TouchDateField.tsx:494 | DateInput | transitionDuration |
| lab/Drawer/Drawer.tsx:161 | Drawer | transitionDuration |
| lab/Drawer/Drawer.tsx:234 | Drawer | transitionDuration |
| core/MobileNav/MobileNav.tsx:132 | MobileNav | transitionDuration |
| core/MobileNav/MobileNav.tsx:162 | MobileNav | transitionDuration |
--duration-instant cannot be assumed to be 0Ship it as 0.01ms and every existing sequencing path keeps working, at a cost no user can perceive. Ship it as 0 and these ten sites have to be rewritten to await something else in the same change — which is a different, larger piece of work than the sweep is scoped for.Decide it before the sweep, not duringThe sweep rewrites these ten lines whichever way the token goes. Deciding halfway through means two idioms in the codebase and a lint rule that cannot tell which one it is looking at.
The rule, as code
What a component author copies. Movement is a multiplier so the policy lands in one place; loops stop rather than slow; hover motion is gated on a fine pointer because touch fires a hover on tap; and the attribute branch exists because an app with its own reduce-motion setting can flip a JS library but cannot flip a media query.The degrade branch — css/* Movement is a multiplier, so the policy has one place to land. */.surface {--motion-translate: 1;--motion-scale: 1;transform: translateY(calc(8px * var(--motion-translate)))scale(calc(1 - 0.06 * var(--motion-scale)));transition:transform var(--duration-enter) var(--ease-entry),opacity var(--duration-enter) var(--ease-entry),background-color var(--duration-state) var(--ease-state);}/* Degrade: movement goes, opacity and colour stay. Not display: none formotion — the feedback is the point, the travel is the problem. */@media (prefers-reduced-motion: reduce) {.surface {--motion-translate: 0;--motion-scale: 0;}/* Loops stop. They do not slow down: slower vestibular motion is stillvestibular motion, and a 3s spinner reads as a hung interface. */.spinner,.progressIndeterminate {animation: none;}}/* Hover motion is gated on a fine pointer as well. A touch device fires ahover on tap, so an un-gated hover animation runs on every tap. */@media (hover: hover) and (pointer: fine) {.row:hover {transform: translateY(calc(-1px * var(--motion-translate)));}}/* An app with its own reduce-motion setting can flip a JS library but cannotflip a media query, so the same rules answer to an attribute too. */[data-reduced-motion='reduce'] .surface {--motion-translate: 0;--motion-scale: 0;}
Both halves ship together or the system disagrees with itself: a chart that animates while the interface around it has stopped is worse than either policy applied consistently.useReducedMotion() — the JS half of the same policy — tsx/*** The JS half of the same policy. Charts, canvas and any motion library read* this; CSS reads the media query. They have to agree, or a chart animates* while the interface around it does not.*/export function useReducedMotion(): boolean {const [reduced, setReduced] = useState(() =>typeof window === 'undefined'? false: window.matchMedia('(prefers-reduced-motion: reduce)').matches,);useEffect(() => {const query = window.matchMedia('(prefers-reduced-motion: reduce)');const onChange = (event: MediaQueryListEvent) => setReduced(event.matches);query.addEventListener('change', onChange);return () => query.removeEventListener('change', onChange);}, []);// The app-level override, so a product setting can force the policy on// without touching the OS. Same attribute the CSS above answers to.const forced = useContext(MotionPreferenceContext);return forced ?? reduced;}