Speed1×
Slow motion multiplier
Reduced motion
Show
JS token mirror
emitted live from this sessionThe read that should not have to exist
MobileNav/MobileNav.tsx:303Today — parse the number back out of the stylesheet
ts// MobileNav/MobileNav.tsx:294-311function resolveCloseDelay(dialog: HTMLDialogElement): number {const cap = window.matchMedia('(prefers-reduced-motion: reduce)').matches? 0: MAX_CLOSE_DELAY_MS;const hold = parseShortestDurationMs(window.getComputedStyle(dialog).transitionDuration,);// The hold is unreadable — an unresolved var()// outside a real browser.if (hold === null) {return cap;}return hold <= 0 ? 0 : Math.min(cap, hold * CLOSE_WITHIN_HOLD);}// plus parseShortestDurationMs at :265 — 25 lines// that exist only to turn "0.41s, 0.12s" back into// a number, exported so it can be unit tested.
With the mirror
tsimport {duration} from '@astryxdesign/core/motion';function resolveCloseDelay(prefersReducedMotion: boolean): number {const cap = prefersReducedMotion ? 0 : MAX_CLOSE_DELAY_MS;// The same value the stylesheet uses, theme-resolved,// and readable in jsdom.const hold = duration.overlay * 1000;return Math.min(cap, hold * CLOSE_WITHIN_HOLD);}
MobileNav is doing the right thing for the right reason. Its own comment says so: the hold is
Worth saying plainly: 34 is the whole computed-style caseload, not the motion one. The motion argument rests on two sites and on everything that cannot read a stylesheet at all — which is the next card.
--duration-medium, which themes rewrite — the shipped y2k theme sets it to exactly 250ms — so read the hold in effect rather than assuming it. Getting that value costs a computed-style read, a 25-line time parser exported for its own unit test (
MobileNav/MobileNav.tsx:265), and a null branch for the case where the read comes back as an unresolved var() outside a real browser. That is exactly right, and exactly what nobody should have to write by hand.The opposite failure — a hardcoded unmount timer drifting away from the transition it is supposed to outlast — is the one the brief warns about, and the audit does not find it. TIMEOUT_LITERALS holds exactly 1 entry: core/MultiSelector/hooks.ts:240 (500ms), and it is a typeahead buffer reset, not motion at all. So core is currently on the safe side of that trade and paying for it in ceremony. The mirror does not fix a bug here; it removes the ceremony, and it makes the same number available to code that has no stylesheet to read.| Read | What it wants | Mirror replaces it? |
|---|---|---|
| MobileNav/MobileNav.tsx:303 | transitionDuration, to time the dialog close inside the hold | yes |
| BottomSheet/BottomSheetPanel.tsx:326 | transitionProperty, transitionDuration and transitionDelay, to know whether a transitionend will arrive | partly |
| the other 32 | direction, writing mode, padding, border radius, scroll margin — layout, not motion | not its job |
Charts are outside the token system entirely
live, on a loopToday — data replaces itself
No token is reachable from here, so the honest default is no animation at all. The chart jumps and the eye loses which bar was which.
With the mirror — interpolated on the reveal token
scaledMs('--duration-reveal') and bezierAt(parseBezier(rawToken('--ease-move')), k) — the same duration and the same curve the CSS uses, applied per frame.Currently 310ms at the rail’s speed, on
cubic-bezier(0.77, 0, 0.175, 1). Change either on the tokens page and both this chart and every CSS demo in the lab move together — which is the whole claim: one value, two consumers.The module, as it stands right now
Emitted from whatever this session has tuned, including springs from the springs page. Copy it and it is a working module.@astryxdesign/core/motion — generated — ts// @generated from the Astryx theme by scripts/generate-motion-mirror.mjs.// Seconds and numbers, because that is what animation libraries, the Web// Animations API and canvas take. Regenerate when the theme changes./** Seconds. Motion, WAAPI and canvas all want a number here. */export const duration = {instant: 0,press: 0.13,state: 0.175,enter: 0.23,exit: 0.175,reveal: 0.31,overlay: 0.41,continuous: 0.975,} as const;/** Four control points, or the CSS keyword when the curve is a keyword. */export const ease = {entry: [0.23, 1, 0.32, 1],exit: [0.3, 0, 0.6, 0.6],move: [0.77, 0, 0.175, 1],state: "ease",linear: "linear",drawer: [0.32, 0.72, 0, 1],} as const;/** Seconds between items in a group entrance. */export const stagger = {tight: 0.03,base: 0.05,loose: 0.08,} as const;/** No CSS form at all, so these are defined here rather than mirrored. */export const spring = {press: {duration: 0.3, bounce: 0.1},swap: {duration: 0.4, bounce: 0.15},panel: {duration: 0.5, bounce: 0.2},layout: {duration: 0.5, bounce: 0.15},} as const;
Why the defaults are not hardcoded in JS
A theme sets three numbers —
{fast, medium, ratio} — and expandMotionScale derives the whole duration scale from them; the shipped y2k theme lands --duration-medium on 250ms rather than 410ms. A mirror written as literals would be right for the default theme and wrong for every other one, which is the failure mode MobileNav is already defending against by reading the computed value.So the mirror parses the same token values the stylesheets use, through the resolver core already has: resolveThemeTokens(theme, {mode}) returns concrete strings with var() references already followed, and needs no React context and no browser. Springs are the exception and are defined in the mirror natively, because they have no CSS form to parse.How the emitter gets its numbers — tsimport {resolveThemeTokens} from '@astryxdesign/core/theme/tokens';const tokens = resolveThemeTokens(theme, {mode: 'light'});duration.overlay = parseMs(tokens['--duration-overlay']) / 1000;ease.move = parseBezier(tokens['--ease-move']);// spring.* has no token to read — it is authored here.
StyleX vars are not the mirror
tsimport {durationVars, easeVars} from '@astryxdesign/core/theme/tokens.stylex';durationVars['--duration-medium']; // "var(--x1kg2b7)" — a referenceeaseVars['--ease-standard']; // "var(--x9f0d3a)" — same// Correct in StyleX:stylex.create({panel: {transitionDuration: durationVars['--duration-medium']}});// Useless anywhere else:animate(el, {opacity: 1}, {duration: durationVars['--duration-medium']}); // NaNctx.globalAlpha = progress(durationVars['--duration-medium']); // NaN
stylex.defineVars hands back opaque var(...) strings. That is the correct contract for a stylesheet and it is precisely the gap: the value never becomes a number on the JS side. The mirror is the second half of the same token, not a competing source of truth.