Speed
Slow motion multiplier
1×
Reduced motion
Show

Rubric bench

13 criteria
Twelve criteria, each with a pass and a fail you can watch. Astryx already gates lab-to-core promotion on an accessibility checklist; motion works the same way or it does not work at all — published, graded per component, tracked.Decide here: Whether each criterion is checkable, and what a pass and a fail look like.

How it grades

No open blockers, and at most two open "should fix" items. Polish items are recorded and never block.
SeverityWhat it meansEffect on the gate
BlockerThe motion is wrong in a way that costs comprehension, accessibility or frame rate.Open blockers fail the review. No exceptions, no "fix it in the follow-up".
Should fixThe motion works but is not what the system would do.Two open items are tolerated. A third fails, so the debt cannot accumulate quietly.
PolishA judgement call about feel, where reasonable people differ.Recorded, never blocking. Argue it in the review, not in the gate.
Blockers: 7 of 13. Mechanical today: 4. A component with two open should-fix items still passes; a third stops it, which is what keeps the second one from becoming permanent.

The thirteen

1. Justified motion

BlockerHuman judgement
Every animation names its purpose: orientation, continuity, feedback or attention.
PassesThe reason survives the question "what breaks if we delete it?".
Fails"It looks good" on an element the user sees dozens of times a day.
Checked by: Reviewer. The justification is written in the diff, not inferred from it.Measured: Deleting the animation is a valid outcome of this criterion, and on a high-frequency surface it is usually the strongest fix available.

2. Frequency-appropriate

BlockerHuman judgementAlready published
Motion is scaled to frequency. Keyboard-initiated actions and anything seen 100+ times a day get none.
PassesA checkbox tick, a table row hover and a menu keyboard traversal are instant.
FailsA 200ms draw-on tick, on a control used forty times in a form.
Checked by: Reviewer, against how often the surface is actually seen. No tool knows that.Measured: 8 of the measured hardcoded values are 150ms tints in Table, on exactly the interaction that paragraph warns about.Pulls against criterion 7: This criterion decides whether a presence surface earns an exit at all. Criterion 7 as the brief wrote it overrode that for anything classed as an overlay, which made Tooltip simultaneously required to have no motion and required to animate out. Criterion 7 has been narrowed so this one wins.Cites the published Motion page: The published page already names table row hovers and list item highlights under "Where Motion Hurts". The rubric formalises it; it does not invent it. See the conflicts
Fails — a tick that draws itself, on a control used forty times in a form
Draws along its path
Passes — instant, because this is a 100+/day interaction
Real CheckboxInput — the tick appears instantly

3. Token fidelity

BlockerMechanical today
No hardcoded durations, delays or curves. No transitionProperty without a duration. No duration without a declared curve.
PassesEvery value is a token, and the pair is complete.
FailstransitionDuration: '150ms', or a duration with the curve left to the CSS default.
Checked by: Lint. Reads the StyleX rule object; no runtime, no reviewer.Measured: Every hardcoded duration and curve, every duration with no declared curve, and every transitionProperty with no duration. 14 of the hardcoded values are the 0.01s reduced-motion escape, which needs an allowlist rather than a fix. See /motion/violations for the live counts.

4. Semantic correctness

Should fixPartly — lint narrows it, a human decides
Entry decelerates. Exit accelerates. Movement uses the move curve. Continuous motion is linear.
Passes--duration-exit with --ease-exit: the surface leaves and gets out of the way.
Fails--duration-exit with --ease-entry: an exit that spends its travel in the first frames and then hangs.
Checked by: Lint can check the pairing once semantic tokens exist. Whether a surface is entering or leaving is a reviewer call.
Fails — exit duration on the entry curve
Passes — exit duration on the exit curve

5. Duration budget

Should fixMechanical today
Interface motion stays under 300ms. Overlays may reach 500ms.
PassesEnter at 230ms, overlay at 410ms.
FailsA 600ms hover, or an 800ms dialog that has to be sat through before the first click.
Checked by: Lint, against the token values. Arithmetic, not taste.
--duration-enter
230ms
Inside the interface budget

6. Compositor-only

BlockerPartly — lint narrows it, a human decides
Animate transform and opacity. clip-path is sanctioned; height is tolerated for disclosure only; transition: all is never acceptable.
PassesA marker that moves with translateY and scaleY, so the list never re-lays-out.
FailsAnimating top and height on a list marker, which runs layout for the whole list every frame.
Checked by: Lint reads the animated property list. Whether the surface is large enough for it to matter is a reviewer call. Blocker on large surfaces — lists, tables, sheets. Should fix on a single small element.Measured: transition: all is measured at 0 sites today. The rule is a guard against regression, not a cleanup.
Fails — animates top and height
Animates top and height, so every frame runs layout, paint and composite for the whole list.
Passes — transform only
Animates transform only, using scaleY on a 1px marker. Compositor-only, so the list never re-lays-out.

7. Enter and exit

BlockerPartly — lint narrows it, a human decidesNarrowed to fit its sources
An exit is animated when it aids orientation — the surface is spatially anchored, or its dismissal reveals what was underneath. When animated it retraces the entry path. A surface the user has already looked away from may dismiss instantly.
PassesA drawer that slid in from the right slides back out to the right, on the exit curve. A tooltip disappears instantly, because the pointer has already left it.
FailsA drawer that slides in and is removed on the next frame. Or an exit that leaves by a different edge than it arrived from.
Checked by: Lint can see whether a component only ever transitions on mount. Whether the exit retraces the entry needs eyes. Blocker for surfaces whose dismissal reveals content underneath — dialogs, drawers, sheets, lightboxes. Not applicable to surfaces the user has already looked away from.Pulls against criterion 2: As the brief wrote it, this criterion and criterion 2 could not both be satisfied. Tooltip is the highest-frequency surface in the system, so criterion 2 says give it no motion; it is also an overlay, so criterion 7 made an instant dismissal a blocker. The narrowing resolves it — frequency decides whether an exit is warranted, and this criterion governs only the exits that are.Claimed more than its sources support: Narrowed from the brief, which failed any surface that animated in and vanished out. That version contradicted the published page and the brief’s own cited source: Emil’s guidance says high-frequency UI often should not animate its exit at all. Orientation is the test, not presence. What survives unchanged is the direction rule, which every source states outright. See the conflicts
Fails the criterion — and follows the published page
No exit at all
Passes — exit retraces the entry, faster
Exits on --duration-exit

8. Origin and physicality

Should fixPartly — lint narrows it, a human decidesAlready published
Trigger-anchored surfaces scale from their trigger, not their centre. Modals are exempt. Never scale(0): start between 0.9 and 0.97 with opacity.
PassesA menu growing out of the corner of the button that opened it.
Failsscale(0) — nothing in the physical world appears from nothing.
Checked by: Lint catches scale(0) and a missing transform-origin. Whether the origin matches the trigger needs eyes.Cites the published Motion page: The published page already says contextual UI should feel connected to its trigger, and already carries the exemption for global UI. This criterion can cite it rather than re-argue it. See the conflicts
Fails — scale(0)
scale(0) — appears from nothing
Nothing in the real world appears from nothing. Start from 0.9–0.97 with opacity instead.
Half right — 0.94, wrong origin
Scales from its own centre
Right scale value, wrong origin: the surface grows out of its own middle rather than out of the control that opened it.
Passes — anchored to the trigger
Scales from the trigger corner
Anchored to the trigger corner, which is what the published page already asks for.

9. Interruptibility

Should fixPartly — lint narrows it, a human decides
Transitions and springs retarget from the current value. Keyframes restart from zero and fail this.
PassesInterrupt the travel and the element continues from where it is.
FailsInterrupt it and the element teleports back to the start before running again.
Checked by: Lint can flag a keyframe driving a state change. Judging the interrupted feel needs the demo.
Fails — a keyframe restarts from zero
A keyframe restarts from zero. Interrupt it mid-flight and the box teleports back to the start before running again.
Passes — a transition retargets
A transition retargets from wherever the box currently is, so an interruption is continuous. Springs behave the same way and also carry velocity through.

10. Reduced motion

BlockerMechanical todayReverses published guidance
Honoured, and degraded rather than deleted: movement and position change go, opacity and colour stay. Hover motion is gated behind a fine pointer, since touch fires a hover on tap. Continuous loops stop rather than slow down.
PassesThe loop stops and is replaced by a determinate state; the tint still tells you the row is hovered.
FailsA spinner slowed to 3s, which is still vestibular motion and now also reads as a hang.
Checked by: Lint. An animating file with no prefers-reduced-motion branch is mechanically detectable.Measured: 40 of 88 animated files have no reduced-motion branch at all.Contradicts the published Motion page: The published page says to replace animation with instant state changes — delete, not degrade. Choosing degrade is a change of published policy, and the 36-file sweep writes whichever answer wins 36 times. See the conflicts
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 — what the published page says
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 — what the criterion says
Degrade: loops stop and are replaced with a static determinate state. Opacity and colour feedback survive; movement does not.

11. Cohesion

PolishHuman judgement
Motion matches the component’s personality and the library it lives in. Group entrances stagger between 30ms and 80ms.
PassesA crisp component in a crisp library; a list that enters at 50ms intervals.
FailsOne bouncy component in a library that is otherwise crisp. That is a finding, not a flourish.
Checked by: Reviewer, against the rest of the library rather than against a number.

12. Library compatibility

Should fixMechanical today
The root forwards a ref to a real DOM node, merges className and style with the consumer last, and puts no CSS transition on transform.
PassesPress feedback on the independent scale property, so a motion library can own transform.
Failstransition: transform on the root — every transform a library writes gets re-eased, so drags lag and springs never settle.
Checked by: Lint plus a render test: forwarded ref, merged className and style, no CSS transition on transform.Measured: Transform transitions across the package, including Button. 14 of them declare transform inside a longer property list, where a grep for it never looks. The generated audit undercounted this twice before the scanner learned to read wrapped values and nested StyleX rules; the brief’s "20+ components" was right all along.
Fails — transition on transform
Press it, then run the drag. The CSS transition re-eases every transform the library writes, so the drag lags the pointer and a spring never settles.
Passes — press on the scale property
Press feedback on the independent scale property, so it no longer collides with the library’s transform writes. Both work — this is the middle path worth prototyping.

13. Idle cost

BlockerPartly — lint narrows it, a human decidesAlready published
A loop animates transform and opacity only, paints inside its own box (contain: paint plus a deliberate layer gated on prefers-reduced-motion: no-preference), and has an arm that stills it. Motion that cannot be a transform is stepped with steps() at the lowest legible cadence, never per frame.
PassesA spinner that rotates on transform inside a contained box, and stops — rather than slows — under reduced motion.
FailsA shimmer on background-position, a pulse on box-shadow, or a bar on inset. Each repaints or re-lays-out every frame, forever, on a page where nothing is happening.
Checked by: Lint can read the animated property off an infinite keyframe set and check for a reduced-motion arm. Whether the containment is right, and whether the loop should exist at all, needs a trace. Blocker for anything that loops. Not applicable to one-shot transitions, which criterion 6 already governs.Measured: 13 loops measured across core, lab and this lab. 5 animate a paint-only or layout property; 8 of the 8 the packages publish are uncontained, and one has no reduced-motion arm at all.Cites the published Motion page: The published page already warns that motion the user sees constantly is where motion hurts. This criterion is that warning applied to the one case where the cost does not stop when the user does. See the conflicts

What the rubric would have caught

Measured against @astryxdesign/core by the audit script, not read out of the brief. These are the rows a reviewer would have been handed on the day the component landed.

Criterion 6 — compositor-only

34 transform transitions across 23 components. Every one of them is also a criterion-12 finding.
SiteDeclaration
core/Banner/Banner.tsx:340transitionProperty: 'transform'
core/BottomSheet/BottomSheetPanel.tsx:144transitionProperty: 'transform, opacity'
core/Button/Button.tsx:91transitionProperty: 'background-image, background-color, color, opacity, transform'
core/Chat/ChatDictationButton.tsx:70transitionProperty: 'transform, background-color'
core/Chat/ChatLayoutScrollButton.tsx:71transitionProperty: 'opacity, transform, max-width'
core/CodeBlock/CodeBlock.tsx:207transitionProperty: 'transform'
14 of these declare transform inside a longer property list rather than on its own, so a grep for transitionProperty: ‘transform’ finds barely half of them. That is worth knowing before anyone scopes criterion 12 from a search — the bugs page has the whole list, and the count has already moved twice as the scanner improved.

Criterion 10 — reduced motion

40 of 88 animated files have no reduced-motion branch. This is the sweep.
ComponentFiles with no branch
Table6
TopNav4
Chat2
DropdownMenu2
TabList2
(shared)1
Table alone accounts for 6 of the 40. The full worklist is on the reduced-motion page, where it also has to wait on degrade-or-delete: the sweep writes whichever answer wins 40 times.

Criterion 3 — token fidelity

59 hardcoded values: 35 durations, 24 curves. The brief budgeted for 28.
SitePropertyValue
Not all of them are carelessness: 14 are the 0.01s reduced-motion escape and 8 are the same 150ms tint in Table. The rubric would have caught every one at review time, which is the argument for the lint rule rather than the sweep.

Automatable?

4 of 13 are mechanical today — 3, 5, 10, 12. Five more can be narrowed by lint to a question a reviewer answers in one line. Three are judgement and always will be.
#CriterionAutomatableWhat checks it
1Justified motionHuman judgementReviewer. The justification is written in the diff, not inferred from it.
2Frequency-appropriateHuman judgementReviewer, against how often the surface is actually seen. No tool knows that.
3Token fidelityMechanical todayLint. Reads the StyleX rule object; no runtime, no reviewer.
4Semantic correctnessPartly — lint narrows it, a human decidesLint can check the pairing once semantic tokens exist. Whether a surface is entering or leaving is a reviewer call.
5Duration budgetMechanical todayLint, against the token values. Arithmetic, not taste.
6Compositor-onlyPartly — lint narrows it, a human decidesLint reads the animated property list. Whether the surface is large enough for it to matter is a reviewer call.
7Enter and exitPartly — lint narrows it, a human decidesLint can see whether a component only ever transitions on mount. Whether the exit retraces the entry needs eyes.
8Origin and physicalityPartly — lint narrows it, a human decidesLint catches scale(0) and a missing transform-origin. Whether the origin matches the trigger needs eyes.
9InterruptibilityPartly — lint narrows it, a human decidesLint can flag a keyframe driving a state change. Judging the interrupted feel needs the demo.
10Reduced motionMechanical todayLint. An animating file with no prefers-reduced-motion branch is mechanically detectable.
11CohesionHuman judgementReviewer, against the rest of the library rather than against a number.
12Library compatibilityMechanical todayLint plus a render test: forwarded ref, merged className and style, no CSS transition on transform.
13Idle costPartly — lint narrows it, a human decidesLint can read the animated property off an infinite keyframe set and check for a reduced-motion arm. Whether the containment is right, and whether the loop should exist at all, needs a trace.

Grandfathering

The rubric is worth nothing if it fails 48 animating components on the day it lands, and worth nothing if it never fails anything. This is the split.
SituationRuleWhy
Lab to core promotionThe full rubric applies from day one. No baseline, no allowlist.Promotion is the one moment where the cost of the rubric is already budgeted, and the same gate already exists for accessibility.
A new core componentFull rubric, no baseline.Nothing to grandfather.
Existing core componentsScored once, baselined, and allowlisted with a dated remediation task per blocker. The allowlist can only shrink.A gate that fails 48 animating components on the day it lands gets turned off in a week. A baseline that can only shrink is the same rule with a schedule.
An unrelated change to a failing componentNot blocked. The rule is no new violations against the baseline.Blocking a copy fix on a motion debt nobody in the diff created is how a gate loses its constituency.
A motion change to a failing componentMust clear that component’s blockers before it lands.If you are already in the motion code, this is the cheapest this fix will ever be — and it is the only way the allowlist actually shrinks.

Every loop in the system, and what it costs

Core is clean on the part that matters most: all five of its loops animate opacity or transform, and all five have a reduced-motion arm. The two high-cost loops are both in lab — ChatReasoning animates background-position with no reduced-motion arm at all, and CircularProgress animates SVG stroke geometry. The systemic gap is containment: of the nine loops the two packages publish, exactly one takes a deliberate layer (Spinner) and none are paint-contained, so every loop can repaint beyond its own box. That costs least on a lone spinner and most on Skeleton and StatusDot, which are the two that multiply.
Why a loop gets its own criterion
A looping animation runs forever, so it is judged on idle cost, not duration. Compositor properties only; contain the paint and take a deliberate layer; gate the layer on prefers-reduced-motion: no-preference; and give every loop an arm that stills it.kt-fr88: an ambient colour fade repainted the full document at 60fps on an idle page — 1,591 document paints in 28s, GPU 78% busy. D117309888 cut a 4s idle window from 1,414 paints to 93, raster tasks from 12,060 to 480, and style time from 543ms to 155ms.
LoopAnimatesContainedReduced-motion armIdle cost
Spinnercore/Spinner/Spinner.tsxThe best-behaved loop in either package, and the only one that takes a deliberate layer. Still not paint-contained, and the layer is unconditional rather than gated on prefers-reduced-motion.
transform: rotatenoyesLow
Skeletoncore/Skeleton/Skeleton.tsxCorrect property, and the most-multiplied loop in the system: a loading table is dozens of these at once, each repainting uncontained. This is the strongest containment candidate in core.
opacitynoyesMedium
ProgressBarcore/ProgressBar/ProgressBar.tsxThe indeterminate loop is transform and fine. Separately, line 244 transitions `width` on the determinate fill — one-shot per data change, so not a loop, but a bar fed by a fast upload ticks often enough to behave like one.
transform (indeterminate slide)noyesMedium
StatusDotcore/StatusDot/StatusDot.tsxCorrect property. Multiplies the same way Skeleton does — a status column is one of these per row, all pulsing uncontained.
opacitynoyesMedium
Chat (message metadata)core/Chat/ChatMessageMetadata.tsxCorrect property, single small instance, so the containment gap is theoretical here rather than urgent.
opacitynoyesMedium
Chat (typing indicator)lab/Chat/ChatTypingIndicator.tsxCorrect properties. Three dots, each its own loop, staggered by hardcoded 160ms and 320ms delays.
transform + opacitynoyesMedium
ChatReasoninglab/ChatReasoning/ChatReasoning.tsxThe worst loop in either package, and the only one failing on every axis at once: a paint-only property, uncontained, no layer, and no reduced-motion arm — running for 4s per iteration, forever, behind streaming text the user is already reading.
background-positionnonoHigh
CircularProgresslab/CircularProgress/CircularProgress.tsxSVG geometry, so the path is re-rasterised every frame. The sibling rotation loop on the same component is a clean transform — the two sit side by side, which makes this the clearest teaching case in the audit. Has no row in the preview plan at all.
stroke-dasharray + stroke-dashoffsetnoyesHigh
Loop demo — spinnerMotionLab.module.css .loopSpinnerContained after this audit.
transform: rotateyesyesLow
Loop demo — pulse dotMotionLab.module.css .pulseDotA spreading box-shadow on a colour-mix, per frame — structurally the kt-fr88 bug. Kept deliberately: it is the demo of what the rule forbids, and it is now paint-contained so the demonstration cannot repaint the page around it.
box-shadowyesyesHigh
Loop demo — shimmerMotionLab.module.css .shimmerThe skeleton-shimmer idiom, and the same violation as ChatReasoning. Kept as the demo; contained after this audit.
background-positionyesyesHigh
Loop demo — progress (width)MotionLab.module.css .progressFillWidthA layout property animated continuously — the most expensive shape available, running layout for its containing block every frame. This is the "before" pane of the compositor demo, so it earns its place, but it was uncontained until this audit.
inset-inline-startyesyesHigh
Loop demo — progress (transform)MotionLab.module.css .progressFillTransformThe "after" pane, and already the target shape: transform with a deliberate layer.
transform: translateXyesyesLow

A principle the rubric drops

Direction should match the actionPublished, ungraded
Navigating deeper into content should feel like moving forward. Going back should feel like returning.
Nothing in the twelve criteria covers directionality.Pagination and Calendar month-change are the surfaces this would be measured against. Both are audit gaps today with no criterion to fail.Recommendation: Fold it into criterion 4 as a fourth pairing rule, or add it as a fourteenth criterion — thirteen is now idle cost. Either way the rubric should be a superset of the published guidance, not a divergent list.The published page against the proposal, conflict by conflict