Speed1×
Slow motion multiplier
Reduced motion
Show
Small bugs
1 of 4 survives as writtenThe three that are not dead
No findingProperty, duration and curve, all three tokenised, paired withSelector/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)',},
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 intendedThe zoomed image transitionsLightbox/Lightbox.tsx:180-194, applied at :691 — tsximage: {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:691isDragging && styles.imageDragging,
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
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 findingease — 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.| Site | Declaration | Reading |
|---|---|---|
| core/Chat/ChatComposerDrawer.tsx:206 | transitionDuration: durationVars['--duration-fast'] | Substantive: a real duration silently taking the CSS default |
| core/CommandPalette/CommandPaletteInput.tsx:59 | transitionDuration: '1ms' | Substantive: a real duration silently taking the CSS default |
| core/DateInput/MonthScroller.tsx:166 | transitionDuration: durationVars['--duration-fast'] | Substantive: a real duration silently taking the CSS default |
| core/DateInput/TouchDateField.tsx:438 | transitionDuration: SWAP_DURATION | Substantive: a real duration silently taking the CSS default |
| core/DateInput/Wheel.tsx:111 | transitionDuration: durationVars['--duration-fast'] | Substantive: a real duration silently taking the CSS default |
| core/MobileNav/MobileNav.tsx:74 | transitionDuration: durationVars['--duration-medium'] | Substantive: a real duration silently taking the CSS default |
| core/MobileNav/MobileNav.tsx:130 | transitionDuration: '0.01s' | Reduced-motion escape — the curve is irrelevant at 10ms |
| core/Stepper/Step.tsx:405 | transitionDuration: | Substantive: a real duration silently taking the CSS default |
| core/Table/plugins/groupedRows/useTableGroupedRows.tsx:177 | transitionDuration: '150ms' | Substantive: a real duration silently taking the CSS default |
| core/Table/plugins/tree/useTableTreeData.tsx:265 | transitionDuration: '150ms' | Substantive: a real duration silently taking the CSS default |
| core/hooks/useKeyboardHint.tsx:107 | transitionDuration: '150ms' | Substantive: a real duration silently taking the CSS default |
| lab/Drawer/Drawer.tsx:232 | transitionDuration: '0.01s' | Reduced-motion escape — the curve is irrelevant at 10ms |
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
Confirmed in labThe brief is right, to the millisecond.
lab/Drawer/Drawer.tsx:459 hardcodes a 250ms close timer, while :157 and :219 set the panel and scrim transitions to --duration-medium — 410ms. The dialog is closed at 61% of its own travel, so the last 160ms of the slide never renders.The reduced-motion branch beside it is the tell: the same expression picks 10ms when the user asks for less motion, which means someone thought carefully about the timer and still had to restate the duration by hand. Nothing in either package derives a timer from its transition; MobileNav is the only component that even tries, and it does it by reading getComputedStyle. That is the argument for the JS token mirror in one file.Page behind
DrawerSlides on --ease-drawer. Removed by a timer that does not know about it.
Slide — 410ms
Unmount timer — 250ms
Slide duration
Unmount timer
What the fix does: the panel unmounts when its own animation finishes, so the two numbers cannot drift apart.
The bug is not the number 250. It is that two numbers have to agree and only one of them is a token: retuneThe 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);}});
--duration-overlay and the timer keeps its old value, so the cut appears in a diff that never touched the drawer.transition: all
0 todayYes, 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 scanner21 of the 34 transform transitions declare
transform on its own. 13 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.| Site | Declaration |
|---|---|
| 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/ComplexSelector/ComplexSelector.tsx:128 | transitionProperty: 'background-image, background-color, color, opacity, transform' |
| core/MultiSelector/MultiSelector.tsx:208 | transitionProperty: 'background-image, background-color, color, opacity, transform' |
| core/Overlay/OverlayScrim.tsx:58 | transitionProperty: 'opacity, visibility, transform' |
| core/Resizable/ResizeHandle.tsx:194 | transitionProperty: 'opacity, background-color, transform' |
| core/Selector/Selector.tsx:230 | transitionProperty: 'background-image, background-color, color, opacity, transform' |
| core/Switch/Switch.tsx:288 | transitionProperty: 'transform, width, height' |
| core/Table/plugins/rowExpansion/useTableRowExpansion.tsx:86 | transitionProperty: 'transform, color' |
| core/Toast/Toast.tsx:61 | transitionProperty: 'opacity, transform' |
| core/TopNav/TopNavMegaMenu.tsx:131 | transitionProperty: 'opacity, transform, overlay, display' |
Audit, fixed: 34 sites / 23 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
| Finding | Lint rule | Caseload today |
|---|---|---|
| A duration with no declared curve | require-timing-function — a transitionDuration in a rule with no transitionTimingFunction and no shorthand. | 12 sites today, 10 of them substantive. |
| A transitionProperty with no duration | no-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: all | no-transition-all — a property list containing all. | 0 today. Pure regression guard. |
| A hardcoded duration, delay or curve | motion-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 transition | no-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 transform | no-transform-transition — criterion 12, the motion-library blocker. | 34 sites across 23 components — criterion 12's entire caseload. |