Speed
Slow motion multiplier
1×
Reduced motion
Show

Export tuning

proposal defaults
Everything tuned in this session, as the two artefacts the foundation milestone ships: the token block and the JS mirror. Plus the lint entries that stop the next one from arriving.Decide here: Nothing — it emits whatever the rest of the lab has been tuned to.

What moved

Nothing has been tuned this session

Every value below is the proposal exactly as authored. Change a curve on the tokens page or a spring on the springs page and the differences appear here.

The token block

Highlighted lines are the addition. Core defines tokens through stylex.defineVars, so this is a diff against two existing objects rather than a :root block — :root would bypass the theme system entirely and could not be retuned by a theme. Semantic durations emit as var(--primitive), which is the shape typeScaleDefaults already uses for type, so a theme that moves the motion scale moves these with it.
@astryxdesign/core/src/theme/tokens.stylex.tsts
// @astryxdesign/core/src/theme/tokens.stylex.ts
// Additions only. The nine primitives and --ease-standard do not move.
export const easeDefaults = {
'--ease-standard': 'cubic-bezier(0.24, 1, 0.4, 1)', // kept; deprecated in docs, not in code
'--ease-entry': 'cubic-bezier(0.23, 1, 0.32, 1)',
'--ease-exit': 'cubic-bezier(0.3, 0, 0.6, 0.6)',
'--ease-move': 'cubic-bezier(0.77, 0, 0.175, 1)',
'--ease-state': 'ease',
'--ease-linear': 'linear',
'--ease-drawer': 'cubic-bezier(0.32, 0.72, 0, 1)',
} as const;
export const easeVars = stylex.defineVars(easeDefaults);
export const durationDefaults = {
'--duration-fast-min': '130ms',
'--duration-fast': '175ms',
'--duration-fast-max': '230ms',
'--duration-medium-min': '310ms',
'--duration-medium': '410ms',
'--duration-medium-max': '550ms',
'--duration-slow-min': '730ms',
'--duration-slow': '975ms',
'--duration-slow-max': '1300ms',
// Semantic layer. Aliased, so the motion scale a theme sets still owns
// these — the same shape typeScaleDefaults uses for type.
'--duration-instant': '0ms', // no primitive: the reduced-motion escape
'--duration-press': 'var(--duration-fast-min)',
'--duration-state': 'var(--duration-fast)',
'--duration-enter': 'var(--duration-fast-max)',
'--duration-exit': 'var(--duration-fast)',
'--duration-reveal': 'var(--duration-medium-min)',
'--duration-overlay': 'var(--duration-medium)',
'--duration-continuous': 'var(--duration-slow)',
} as const;
export const durationVars = stylex.defineVars(durationDefaults);
// Stagger is a delay between siblings, not a duration, so it gets its own
// group rather than widening DurationVarName.
export const staggerDefaults = {
'--stagger-tight': '30ms',
'--stagger-base': '50ms',
'--stagger-loose': '80ms',
} as const;
export const staggerVars = stylex.defineVars(staggerDefaults);

The JS mirror

Same values, in the shape a JS animation takes. Generated from the theme in the same build step that emits the CSS — see JS token mirror for why it cannot be a handwritten constants file.
@astryxdesign/core/motion — generatedts
// @generated from the Astryx theme by scripts/generate-motion-mirror.mjs.
// Values are resolved through resolveThemeTokens, so a theme that retunes the
// motion scale retunes this file too.
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;
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;
export const stagger = {
tight: 0.03,
base: 0.05,
loose: 0.08,
} as const;
/** No CSS form, so authored 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;

The lint rule

The token linter already polices type, spacing, radius and colour, and simply has no entry for duration or easing. That is the whole reason 58 values are hardcoded: nothing ever told anyone not to. Six property entries drop straight into the existing pattern map — three rules once the duration, delay and timing-function pairs are grouped. The other three are structural: they are properties of the whole style object, and the current rule only ever looks at one literal at a time.
RuleAllowsMeasured catchKind
transitionDuration / animationDurationvar(--duration-*)34 hardcoded durationsvalue
transitionDelay / animationDelayvar(--stagger-*), var(--duration-*)included in the count abovevalue
transitionTimingFunction / animationTimingFunctionvar(--ease-*)24 hardcoded curvesvalue
motion/require-duration-with-propertya duration beside every transitionProperty1 no-op transitionstructural
motion/require-curve-with-durationa curve beside every duration12 durations with no declared curvestructural
motion/no-transition-allnamed properties only0 today — prevents regressionsstructural
internal/eslint-plugin-xds/index.js — value entriesjs
// internal/eslint-plugin-xds/index.js — rule: xds/no-hardcoded-styles
//
// STYLE_PROPERTIES already covers type, spacing, radius and colour. It has no
// motion entry, which is why 58 hardcoded duration and easing values
// pass lint today. Six additions, in the shape the map already uses:
const STYLE_PROPERTIES = {
// ...fontSize, padding, borderRadius, color — unchanged
transitionDuration: {
pattern: /^['"]?[\d.]+m?s['"]?$/,
tokenVar: 'durationVars',
message: 'Use a duration token: var(--duration-*)',
examples: ["durationVars['--duration-enter']"],
},
animationDuration: {
pattern: /^['"]?[\d.]+m?s['"]?$/,
tokenVar: 'durationVars',
message: 'Use a duration token: var(--duration-*)',
examples: ["durationVars['--duration-continuous']"],
},
transitionDelay: {
pattern: /^['"]?[\d.]+m?s['"]?$/,
tokenVar: 'staggerVars',
message: 'Use var(--stagger-*) for a group offset, var(--duration-*) otherwise',
examples: ["staggerVars['--stagger-base']"],
},
animationDelay: {
pattern: /^['"]?[\d.]+m?s['"]?$/,
tokenVar: 'staggerVars',
message: 'Use var(--stagger-*) for a group offset, var(--duration-*) otherwise',
examples: ["staggerVars['--stagger-tight']"],
},
transitionTimingFunction: {
pattern: /^['"]?(cubic-bezier\([^)]*\)|linear|ease(-in|-out|-in-out)?|steps\([^)]*\))['"]?$/,
tokenVar: 'easeVars',
message: 'Use an easing token: var(--ease-*)',
examples: ["easeVars['--ease-entry']", "easeVars['--ease-exit']"],
},
animationTimingFunction: {
pattern: /^['"]?(cubic-bezier\([^)]*\)|linear|ease(-in|-out|-in-out)?|steps\([^)]*\))['"]?$/,
tokenVar: 'easeVars',
message: 'Use an easing token: var(--ease-*)',
examples: ["easeVars['--ease-linear']"],
},
};
// One carve-out is needed. SKIP_VALUES lets '0', 'none' and 'auto' through for
// every property, which is right for padding and wrong for motion: it is what
// hides transitionProperty: 'none'. The structural rules below need to see it.
The structural companionsjs
// A second visitor, because these are properties of the style object rather
// than of one value — the existing rule only ever looks at a single literal.
'motion/require-duration-with-property': {
// transitionProperty with no duration in the same object inherits 0s and
// animates nothing. Catches: 1 today.
},
'motion/require-curve-with-duration': {
// A duration with no timing function silently gets the CSS default (ease),
// which is a decision nobody made. Catches: 12 today.
},
'motion/no-transition-all': {
// transitionProperty: 'all' animates layout and paint properties nobody
// intended. Catches: 0 today — a ratchet, not a cleanup.
},