Speed
Slow motion multiplier
1×
Reduced motion
Show

Small bugs

1 of 4 survives as written
Four structural findings in the brief. One is real and underplayed, one is a misreading of three components, one is correct code the scanner flags, and one cannot be verified in this app at all.Decide here: Which of the structural findings are real once you look at the code.
The three dead no-op transitions are not there
The brief lists three dead no-op transitions in Selector, ComplexSelector and MultiSelector. All three rules exist, and all three carry a tokenised duration and a tokenised curve. The audit finds exactly one no-op in the whole package — core/Lightbox/Lightbox.tsx:196 — and it is a deliberate transitionProperty: ‘none’ that turns a transition off during a drag. Correct code, flagged by a scanner.

The three that are not dead

No finding
Selector, ComplexSelector and MultiSelector all declare the same rotation rule. Is anything missing from it?
Selector/Selector.tsx:201-214 — identical at ComplexSelector.tsx:154-166 and MultiSelector.tsx:178-191 — tsx
// Rotation lives on the chevron glyph itself (passed through `xstyle`), not
// on the layout wrapper above, so the icon's `selector-indicator-icon` theme
// target and the open/closed transform sit on one element — a theme can
// restyle the mark and its rotation through a single selector. The wrapper
// keeps only layout. The status branch renders a different icon, so it never
// picks these up and needs no transition opt-out.
triggerIconRotation: {
transitionProperty: 'transform',
transitionDuration: durationVars['--duration-fast'],
transitionTimingFunction: easeVars['--ease-standard'],
transformOrigin: 'center',
},
triggerIconOpen: {
transform: 'rotate(180deg)',
},
Property, duration and curve, all three tokenised, paired with triggerIconOpen which supplies the transform. There is nothing dead here.The misreading is easy to make and the source explains it: the rotation was moved off the layout wrapper and onto the chevron glyph, which receives it through xstyle. Read the wrapper and you see a component that rotates something with no transition anywhere near it. Worth re-checking before it becomes a milestone line item — it is currently costed as three fixes that do not exist.

The one real no-op is correct

Working as intended
core/Lightbox/Lightbox.tsx:196 — a transitionProperty with no duration. Bug, or opt-out?
Lightbox/Lightbox.tsx:180-194, applied at :691 — tsx
image: {
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
pointerEvents: 'none',
transitionProperty: 'transform',
transitionDuration: {
default: '200ms',
'@media (prefers-reduced-motion: reduce)': '0ms',
},
transitionTimingFunction: 'ease-out',
},
imageDragging: {
transitionProperty: 'none',
},
​
// ...applied only while the pointer is down, Lightbox.tsx:691
isDragging && styles.imageDragging,
The zoomed image transitions transform over 200ms so a zoom step glides. While the pointer is down, imageDragging replaces that with transitionProperty: ‘none’, so the image tracks the pointer 1:1 instead of easing 200ms behind it. Removing it would make dragging feel like dragging through syrup.The audit is right that the shape is a no-op and wrong that it is a defect. The lint rule needs the exemption written in from the start: none is the only value of transitionProperty that legitimately appears without a duration.

Why the shape is still worth linting

Both panes declare a transition. Only one of them animates. Which is which, at a glance in review?
Hover the rows
transitionProperty, no duration — reads like motion, does nothing
Row one
Row two
Row three
The same rule with a duration and a curve
Row one
Row two
Row three
This is the argument for the rule even though the package has one instance and it is exempt: the failure is invisible in review. A reviewer sees the word transition and moves on, and the component ships with a state change that snaps.

12 durations with no declared curve

The largest structural finding
A duration with no timing function silently gets the CSS default, which is ease — not --ease-standard. How different are they?
ease — what these sites actually run
--ease-standard — the curve core ships
ease is cubic-bezier(0.25, 0.1, 0.25, 1) — a gentle symmetrical curve. --ease-standard is cubic-bezier(0.24, 1, 0.4, 1), which leaves immediately and decelerates hard. Every site below is running the first one while its neighbours run the second, and nobody wrote that decision down.
SiteDeclarationReading
core/Chat/ChatComposerDrawer.tsx:222transitionDuration: durationVars['--duration-fast']Substantive: a real duration silently taking the CSS default
core/CommandPalette/CommandPaletteInput.tsx:59transitionDuration: '1ms'Substantive: a real duration silently taking the CSS default
core/DateInput/MonthScroller.tsx:166transitionDuration: durationVars['--duration-fast']Substantive: a real duration silently taking the CSS default
core/DateInput/TouchDateField.tsx:445transitionDuration: SWAP_DURATIONSubstantive: a real duration silently taking the CSS default
core/DateInput/Wheel.tsx:111transitionDuration: durationVars['--duration-fast']Substantive: a real duration silently taking the CSS default
core/MobileNav/MobileNav.tsx:77transitionDuration: durationVars['--duration-medium']Substantive: a real duration silently taking the CSS default
core/MobileNav/MobileNav.tsx:136transitionDuration: '0.01s'Reduced-motion escape — the curve is irrelevant at 10ms
core/Stepper/Step.tsx:451transitionDuration:Substantive: a real duration silently taking the CSS default
core/Table/plugins/groupedRows/useTableGroupedRows.tsx:181transitionDuration: '150ms'Substantive: a real duration silently taking the CSS default
core/Table/plugins/tree/useTableTreeData.tsx:270transitionDuration: '150ms'Substantive: a real duration silently taking the CSS default
core/hooks/useKeyboardHint.tsx:107transitionDuration: '150ms'Substantive: a real duration silently taking the CSS default
lab/Drawer/Drawer.tsx:227transitionDuration: '0.01s'Reduced-motion escape — the curve is irrelevant at 10ms
10 of the 12 are substantive — a real duration silently taking ease — across (shared), Chat, CommandPalette, DateInput, MobileNav, Stepper, Table. Only 2 is the 0.01s escape, where having no curve is the right answer. The brief underplays this finding, and the figure has already moved twice as the scanner improved: read it out of the audit rather than quoting it.

The drawer exit, cut short

Resolved in Lab
Slide 410ms, unmount at 250ms. Drag either slider: at what point does an exit stop being an exit?
This was the lab failure. The former implementation hardcoded a 250ms close timer while the panel and scrim transitions used --duration-medium (410ms by default). The dialog closed at 61% of its own travel, so the last 160ms of the slide never rendered.Lab Drawer now waits for the actual transform transition and uses a computed-duration backstop in lab/Drawer/useDrawerDialogPresence.ts. Theme motion can no longer desynchronize the visual exit from native-host release; the sliders below preserve the original failure as an interactive explanation of why the fix matters.
Page behind
DrawerSlides on --ease-drawer. Removed by a timer that does not know about it.
Slide — 410ms
Unmount timer — 250ms
The panel is removed at 61% of its travel. 39% of the exit never runs.
Slide duration
410ms
Unmount timer
250ms
What the fix does: the panel unmounts when its own animation finishes, so the two numbers cannot drift apart.
The class of bug, and the two ways out of it — tsx
// What the brief describes: a literal that has to be kept in sync by hand.
setTimeout(() => setMounted(false), 250);
​
// What removes the class of bug: the element says when it is done.
const panel = panelRef.current;
if (panel == null) {
setMounted(false);
return;
}
Promise.allSettled(panel.getAnimations().map(a => a.finished)).then(() =>
setMounted(false),
);
​
// The transitionend form, for a single known property. Note the guard: a
// bubbling transitionend from a child would unmount the panel early, and a
// zero duration fires nothing at all — which is why --duration-instant
// cannot simply be 0.
panel.addEventListener('transitionend', event => {
if (event.target === panel && event.propertyName === 'transform') {
setMounted(false);
}
});
The bug is not the number 250. It is that two numbers have to agree and only one of them is a token: retune --duration-overlay and the timer keeps its old value, so the cut appears in a diff that never touched the drawer.

transition: all

0 today
0 sites. Is a rule with nothing to fix worth writing?
Yes, and it is the cheapest rule in the set. transition: all animates every property that ever changes, including layout properties nobody intended to animate, and it arrives in a codebase one careless line at a time. Core is clean today; the rule keeps it clean and costs one regex.

The count moved twice, and the brief was right

Every structural number on this page is a scanner output, so what the scanner can see decides what gets scheduled. This section started as a grep run against a generated count that looked too small; the generator has since been fixed twice, and criterion 12’s caseload went from something smaller than the brief’s estimate to something larger than it.
A scanner sees shape, not intentThe one no-op it reports is the Lightbox drag opt-out above: correct code with the shape of a defect. Every rule the lint gains has to carry its exemptions from the start, or the first thing it produces is work that ends in “working as intended”.
A grep sees less than a scanner22 of the 36 transform transitions declare transform on its own. 14 declare it inside a longer property list, where a search for transitionProperty: ‘transform’ never finds them — which is how one repository yields three different counts depending on who counted.
The multi-property declarations are the interesting half: criterion 12 findings like the rest, and 3 of them criterion 6 findings as well, because they animate a layout property in the same breath as a compositor one.
SiteDeclaration
core/BottomSheet/BottomSheetPanel.tsx:162transitionProperty: 'transform, opacity'
core/Button/Button.tsx:101transitionProperty: 'background-image, background-color, color, opacity, transform'
core/Chat/ChatDictationButton.tsx:71transitionProperty: 'transform, background-color'
core/Chat/ChatLayoutScrollButton.tsx:78transitionProperty: 'opacity, transform, max-width, visibility'
core/ComplexSelector/ComplexSelector.tsx:141transitionProperty: 'background-image, background-color, color, opacity, transform'
core/Item/Item.tsx:484transitionProperty: 'background-color, transform'
core/Item/ItemSwipeLayer.tsx:106transitionProperty: 'transform, width'
core/MultiSelector/MultiSelector.tsx:231transitionProperty: 'background-image, background-color, color, opacity, transform'
core/Overlay/OverlayScrim.tsx:58transitionProperty: 'opacity, visibility, transform'
core/Resizable/ResizeHandle.tsx:219transitionProperty: 'opacity, background-color, transform'
core/Selector/Selector.tsx:250transitionProperty: 'background-image, background-color, color, opacity, transform'
core/Switch/Switch.tsx:323transitionProperty: 'transform, width, height'
core/Toast/Toast.tsx:83transitionProperty: 'opacity, transform'
core/TopNav/TopNavMegaMenu.tsx:133transitionProperty: 'opacity, transform, overlay, display'
Audit, fixed: 36 sites / 24 componentsBrief: 20+ components
This page found the discrepancy by grepping when the generated number looked too small, and the generator has since been fixed twice: once so a value that wraps onto its own line is still read, and once so a rule containing nested per-state objects is still scanned. Both bugs hid matches, and both made the brief look wrong when it was right. The lasting fix is a parser rather than a regex — a StyleX rule is a TypeScript object literal and can be read as one — but the habit matters more than the tool: when a generated count disagrees with someone who has read the code, check the generator first.

What would have caught each of these

FindingLint ruleCaseload today
A duration with no declared curverequire-timing-function — a transitionDuration in a rule with no transitionTimingFunction and no shorthand.12 sites today, 10 of them substantive.
A transitionProperty with no durationno-noop-transition — same shape, inverted. Must exempt transitionProperty: 'none', which is a deliberate opt-out.1 site today, and it is the exemption case.
transition: allno-transition-all — a property list containing all.0 today. Pure regression guard.
A hardcoded duration, delay or curvemotion-tokens-only — any time literal or bezier literal outside the token file.58 today. Needs an allowlist for the reduced-motion escape.
A timer literal standing in for a transitionno-animation-timeout — a setTimeout whose literal matches a duration token value, inside a component that transitions.1 literal timer in core, and it is unrelated to motion.
A CSS transition on transformno-transform-transition — criterion 12, the motion-library blocker.36 sites across 24 components — criterion 12's entire caseload.
Five of the six are mechanical. The sixth — a timer standing in for a transition — is the only one that needs a heuristic, and it is the only one of the six that describes a bug a user would notice.