Speed
Slow motion multiplier
1×
Reduced motion
Show

JS token mirror

emitted live from this session
CSS custom properties are unreadable to every animation library, to the Web Animations API, and to canvas and chart code. Motion takes seconds as a number and easing as a four-number array; it cannot resolve var(--duration-fast). The mirror emits the same values the stylesheets use, in the shape those consumers already expect.Decide here: What the mirror has to expose for charts, canvas and motion libraries.

The read that should not have to exist

MobileNav/MobileNav.tsx:303
34 getComputedStyle reads across core. Two of them read motion values back out of CSS because there is no other way to get them.
Today — parse the number back out of the stylesheet
ts
// MobileNav/MobileNav.tsx:294-311
function 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
ts
import {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 --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.
ReadWhat it wantsMirror replaces it?
MobileNav/MobileNav.tsx:303transitionDuration, to time the dialog close inside the holdyes
BottomSheet/BottomSheetPanel.tsx:326transitionProperty, transitionDuration and transitionDelay, to know whether a transitionend will arrivepartly
the other 32direction, writing mode, padding, border radius, scroll margin — layout, not motionnot its job
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.

Charts are outside the token system entirely

live, on a loop
Canvas cannot resolve a custom property. Chart, Radial, Sankey and Schedule therefore animate on whatever numbers their author typed, or they do not animate.
Today — 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 — generatedts
// @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

Because a theme retunes the whole scale, and a constants file would quietly stop agreeing with the stylesheet.
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 numbersts
import {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

Core already exports durationVars and easeVars. They are the right thing for StyleX and useless everywhere else.
ts
import {durationVars, easeVars} from '@astryxdesign/core/theme/tokens.stylex';
durationVars['--duration-medium']; // "var(--x1kg2b7)" — a reference
easeVars['--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']}); // NaN
ctx.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.
What the mirror does not solve
A second artefact is a second thing to keep in sync. It only stays honest if it is generated from the theme in the same build step that emits the CSS, and if the lint rule treats a hardcoded number in JS the same way it treats one in a stylesheet. Both of those are on Export tuning.