Speed1×
Slow motion multiplier
Reduced motion
Show
Rubric bench
13 criteriaHow it grades
No open blockers, and at most two open "should fix" items. Polish items are recorded and never block.| Severity | What it means | Effect on the gate |
|---|---|---|
| Blocker | The 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 fix | The motion works but is not what the system would do. | Two open items are tolerated. A third fails, so the debt cannot accumulate quietly. |
| Polish | A judgement call about feel, where reasonable people differ. | Recorded, never blocking. Argue it in the review, not in the gate. |
The thirteen
1. Justified motion
BlockerHuman judgementPassesThe 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.
2. Frequency-appropriate
BlockerHuman judgementAlready publishedPassesA 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.
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 interactionReal CheckboxInput — the tick appears instantly
3. Token fidelity
BlockerMechanical todayPassesEvery value is a token, and the pair is complete.
FailstransitionDuration: '150ms', or a duration with the curve left to the CSS default.
4. Semantic correctness
Should fixPartly — lint narrows it, a human decidesPasses--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.
Fails — exit duration on the entry curve
Passes — exit duration on the exit curve
5. Duration budget
Should fixMechanical todayPassesEnter at 230ms, overlay at 410ms.
FailsA 600ms hover, or an 800ms dialog that has to be sat through before the first click.
--duration-enter
Inside the interface budget
6. Compositor-only
BlockerPartly — lint narrows it, a human decidesPassesA 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.
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 sourcesPassesA 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.
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 publishedPassesA menu growing out of the corner of the button that opened it.
Failsscale(0) — nothing in the physical world appears from nothing.
Fails — scale(0)Nothing in the real world appears from nothing. Start from 0.9–0.97 with opacity instead.
scale(0) — appears from nothing
Half right — 0.94, wrong originRight scale value, wrong origin: the surface grows out of its own middle rather than out of the control that opened it.
Scales from its own centre
Passes — anchored to the triggerAnchored to the trigger corner, which is what the published page already asks for.
Scales from the trigger corner
9. Interruptibility
Should fixPartly — lint narrows it, a human decidesPassesInterrupt the travel and the element continues from where it is.
FailsInterrupt it and the element teleports back to the start before running again.
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 guidancePassesThe 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.
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 judgementPassesA 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.
12. Library compatibility
Should fixMechanical todayPassesPress 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.
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 publishedPassesA 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.
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
| Site | Declaration |
|---|---|
| core/Banner/Banner.tsx:340 | transitionProperty: 'transform' |
| core/BottomSheet/BottomSheetPanel.tsx:144 | transitionProperty: 'transform, opacity' |
| core/Button/Button.tsx:91 | transitionProperty: 'background-image, background-color, color, opacity, transform' |
| core/Chat/ChatDictationButton.tsx:70 | transitionProperty: 'transform, background-color' |
| core/Chat/ChatLayoutScrollButton.tsx:71 | transitionProperty: 'opacity, transform, max-width' |
| core/CodeBlock/CodeBlock.tsx:207 | transitionProperty: 'transform' |
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
| Component | Files with no branch |
|---|---|
| Table | 6 |
| TopNav | 4 |
| Chat | 2 |
| DropdownMenu | 2 |
| TabList | 2 |
| (shared) | 1 |
Criterion 3 — token fidelity
| Site | Property | Value |
|---|
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.| # | Criterion | Automatable | What checks it |
|---|---|---|---|
| 1 | Justified motion | Human judgement | Reviewer. The justification is written in the diff, not inferred from it. |
| 2 | Frequency-appropriate | Human judgement | Reviewer, against how often the surface is actually seen. No tool knows that. |
| 3 | Token fidelity | Mechanical today | Lint. Reads the StyleX rule object; no runtime, no reviewer. |
| 4 | Semantic correctness | Partly — lint narrows it, a human decides | Lint can check the pairing once semantic tokens exist. Whether a surface is entering or leaving is a reviewer call. |
| 5 | Duration budget | Mechanical today | Lint, against the token values. Arithmetic, not taste. |
| 6 | Compositor-only | Partly — lint narrows it, a human decides | Lint reads the animated property list. Whether the surface is large enough for it to matter is a reviewer call. |
| 7 | Enter and exit | Partly — lint narrows it, a human decides | Lint can see whether a component only ever transitions on mount. Whether the exit retraces the entry needs eyes. |
| 8 | Origin and physicality | Partly — lint narrows it, a human decides | Lint catches scale(0) and a missing transform-origin. Whether the origin matches the trigger needs eyes. |
| 9 | Interruptibility | Partly — lint narrows it, a human decides | Lint can flag a keyframe driving a state change. Judging the interrupted feel needs the demo. |
| 10 | Reduced motion | Mechanical today | Lint. An animating file with no prefers-reduced-motion branch is mechanically detectable. |
| 11 | Cohesion | Human judgement | Reviewer, against the rest of the library rather than against a number. |
| 12 | Library compatibility | Mechanical today | Lint plus a render test: forwarded ref, merged className and style, no CSS transition on transform. |
| 13 | Idle cost | Partly — lint narrows it, a human decides | 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. |
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.| Situation | Rule | Why |
|---|---|---|
| Lab to core promotion | The 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 component | Full rubric, no baseline. | Nothing to grandfather. |
| Existing core components | Scored 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 component | Not 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 component | Must 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.| Loop | Animates | Contained | Reduced-motion arm | Idle 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: rotate | no | yes | Low |
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. | opacity | no | yes | Medium |
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) | no | yes | Medium |
StatusDotcore/StatusDot/StatusDot.tsxCorrect property. Multiplies the same way Skeleton does — a status column is one of these per row, all pulsing uncontained. | opacity | no | yes | Medium |
Chat (message metadata)core/Chat/ChatMessageMetadata.tsxCorrect property, single small instance, so the containment gap is theoretical here rather than urgent. | opacity | no | yes | Medium |
Chat (typing indicator)lab/Chat/ChatTypingIndicator.tsxCorrect properties. Three dots, each its own loop, staggered by hardcoded 160ms and 320ms delays. | transform + opacity | no | yes | Medium |
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-position | no | no | High |
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-dashoffset | no | yes | High |
Loop demo — spinnerMotionLab.module.css .loopSpinnerContained after this audit. | transform: rotate | yes | yes | Low |
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-shadow | yes | yes | High |
Loop demo — shimmerMotionLab.module.css .shimmerThe skeleton-shimmer idiom, and the same violation as ChatReasoning. Kept as the demo; contained after this audit. | background-position | yes | yes | High |
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-start | yes | yes | High |
Loop demo — progress (transform)MotionLab.module.css .progressFillTransformThe "after" pane, and already the target shape: transform with a deliberate layer. | transform: translateX | yes | yes | Low |
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