/** * Samples M3's motion springs into the `linear()` easings of resources/css/tokens/motion.css. * * Run from the repository root with `npm run build:springs` (or `node bin/springs.mjs`). * Maintenance only: plain Node 22+, no dependencies, deterministic output — running it twice * changes nothing. Paste the blocks it prints over the spring tokens in motion.css. * * node bin/springs.mjs every spring of both schemes, over Google's web durations * node bin/springs.mjs --settle the same springs sampled to their own settle time instead * node bin/springs.mjs 0.6 800 350 one spring: damping ratio, stiffness, duration in ms * * The maths is androidx's `SpringSimulation.updateValues` (Apache-2.0, © Google LLC) for a * spring that starts at rest and travels from 0 to 1, which is what an easing has to describe. * With the natural frequency ω = √stiffness, r = −ζω and the damped frequency ωd = ω√(1 − ζ²): * * underdamped (ζ < 1) x(t) = 1 + e^(rt) · (−cos(ωd·t) + (r / ωd) · sin(ωd·t)) * critical (ζ = 1) x(t) = 1 − (1 + ω·t) · e^(−ω·t) * * The spatial springs are underdamped and overshoot; the effects springs are critically damped * and never do (a colour must not overshoot). An overdamped spring (ζ > 1) is not in either * scheme and is not implemented. * * *Settle time* here is the first whole 10 ms after which the spring stays within 0.1 % of its * travel — found by scanning, because an underdamped spring crosses that band on the way to a * later peak that may still be outside it. It is what the spring costs in real time, not a * token: M3 publishes a duration per spring for the web, and that is what motion.css uses. * * *Sampling* takes the spring's value at real time `duration · i / 48` for i = 0…48 — 49 points, * finer than a frame at 60 Hz — and rounds to four decimals, so the physics play out in real * milliseconds whatever the duration is. Where the duration is past the settle time the tail is * flat at 1; where it falls short the last point is pinned to 1 so the animation still lands * exactly on its target, closing a gap of at most 0.3 % of the travel in the final frame. */ /** Compose's two motion schemes: {Expressive,Standard}MotionTokens.kt, androidx-main @ 1608250. */ const SCHEMES = { expressive: { 'spatial-fast': { damping: 0.6, stiffness: 800 }, 'spatial-default': { damping: 0.8, stiffness: 380 }, 'spatial-slow': { damping: 0.8, stiffness: 200 }, 'effects-fast': { damping: 1, stiffness: 3800 }, 'effects-default': { damping: 1, stiffness: 1600 }, 'effects-slow': { damping: 1, stiffness: 800 }, }, standard: { 'spatial-fast': { damping: 0.9, stiffness: 1400 }, 'spatial-default': { damping: 0.9, stiffness: 700 }, 'spatial-slow': { damping: 0.9, stiffness: 300 }, 'effects-fast': { damping: 1, stiffness: 3800 }, 'effects-default': { damping: 1, stiffness: 1600 }, 'effects-slow': { damping: 1, stiffness: 800 }, }, } /** The durations M3 publishes for the web, one per spring (docs/reference/m3/styles.md § Motion). */ const DURATIONS = { expressive: { 'spatial-fast': 350, 'spatial-default': 500, 'spatial-slow': 650, 'effects-fast': 150, 'effects-default': 200, 'effects-slow': 300, }, standard: { 'spatial-fast': 350, 'spatial-default': 500, 'spatial-slow': 750, 'effects-fast': 150, 'effects-default': 200, 'effects-slow': 300, }, } /** How close to its target the spring has to stay to count as settled: 0.1 % of the travel. */ const THRESHOLD = 0.001 /** The number of points a sampled easing has; 48 intervals divide every duration evenly. */ const POINTS = 49 /** The spring's value `ms` after it starts at rest and travels from 0 to 1. */ function spring({ damping, stiffness }, ms) { const naturalFreq = Math.sqrt(stiffness) const t = ms / 1000 if (damping > 1) { throw new Error(`Overdamped springs (damping ${damping}) are not in either M3 scheme.`) } if (damping === 1) { return 1 - (1 + naturalFreq * t) * Math.exp(-naturalFreq * t) } const r = -damping * naturalFreq const dampedFreq = naturalFreq * Math.sqrt(1 - damping ** 2) return 1 + Math.exp(r * t) * (-Math.cos(dampedFreq * t) + (r / dampedFreq) * Math.sin(dampedFreq * t)) } /** * The first whole 10 ms after which the spring never leaves the 0.1 % band again. * * Scanned in 0.05 ms steps up to where the decay envelope alone is inside the band (for the * critically damped branch, √e times its half-life is a safe bound on the same idea), since the * band can be re-entered and left again while the spring rings down. */ function settleTime(constants) { const naturalFreq = Math.sqrt(constants.stiffness) const decay = constants.damping * naturalFreq const bound = ((Math.log(1 / THRESHOLD) + 10) / decay) * 1000 let last = 0 for (let ms = 0; ms <= bound; ms += 0.05) { if (Math.abs(1 - spring(constants, ms)) >= THRESHOLD) { last = ms } } return Math.ceil(last / 10) * 10 } /** Where an underdamped spring overshoots furthest: half a period of its damped oscillation. */ function peak(constants) { if (constants.damping >= 1) { return null } const dampedFreq = Math.sqrt(constants.stiffness) * Math.sqrt(1 - constants.damping ** 2) const ms = (Math.PI / dampedFreq) * 1000 return { ms: Math.round(ms), value: spring(constants, ms) } } /** The spring as a `linear()` easing of 49 points over `duration` ms. */ function sample(constants, duration) { const points = Array.from({ length: POINTS }, (_, i) => { const value = spring(constants, (duration * i) / (POINTS - 1)) return Number(value.toFixed(4)) }) points[POINTS - 1] = 1 return `linear(${points.join(', ')})` } /** One spring as the CSS motion.css declares it: a derivation comment and the two tokens. */ function declare(name, constants, duration) { const settles = settleTime(constants) const overshoot = peak(constants) const derivation = [ `damping ${constants.damping.toFixed(1)}, stiffness ${constants.stiffness}`, `settles in ${settles}ms`, overshoot ? `peaks at ${overshoot.value.toFixed(3)} at ${overshoot.ms}ms` : null, `sampled over ${duration}ms`, ].filter(Boolean) return [ ` /* ${derivation.join('; ')} */`, ` --md-sys-motion-${name}: ${sample(constants, duration)};`, ` --md-sys-motion-${name}-duration: ${duration}ms;`, ].join('\n') } const args = process.argv.slice(2) const toSettle = args.includes('--settle') const numbers = args.filter((argument) => !argument.startsWith('--')).map(Number) if (numbers.length >= 2) { const constants = { damping: numbers[0], stiffness: numbers[1] } process.stdout.write(`${declare('custom', constants, numbers[2] ?? settleTime(constants))}\n`) } else { const blocks = Object.entries(SCHEMES).map(([scheme, springs]) => [ `/* ${scheme} */`, ...Object.entries(springs).map(([name, constants]) => declare(name, constants, toSettle ? settleTime(constants) : DURATIONS[scheme][name]), ), ].join('\n'), ) process.stdout.write(`${blocks.join('\n\n')}\n`) }