Sharing Motion Tokens Across Web and Native
Part of Motion Design Systems & Tokens in Core CSS Animation Fundamentals.
The problem
The web app’s bottom sheet opens over 320ms with a decelerating curve. The iOS app’s version uses a system default. The Android version uses a hand-copied cubic-bezier whose numbers were transposed. Product reviews compare the three side by side and the same component feels different on each. Every time the design team adjusts the motion scale, three repositories need three different pull requests, and one of them is always forgotten.
Motion tokens are data. Data can have one source and many outputs.
Root cause analysis: platforms share maths, not syntax
Cubic-bezier curves are universal. A CSS cubic-bezier(x1, y1, x2, y2) is defined by two control points. iOS’s UICubicTimingParameters takes the same two control points, and Android’s PathInterpolator (and Compose’s CubicBezierEasing) take the same four numbers. Stored as an array of four numbers, an easing token generates correctly for every platform.
Durations are universal, frame pacing is not. A 240ms duration is 240ms everywhere, but platforms differ in display refresh, system animation scaling and how user settings adjust durations. Android’s developer “animator duration scale”, for instance, multiplies durations system-wide. The token value stays the same; the platform may apply its own factor.
Springs are a different model. Native platforms favour physics-based springs defined by stiffness and damping (or response and damping fraction), where duration is an output rather than an input. CSS has no spring timing function; it can approximate a spring with linear() sampled from the physics, as described in approximating spring physics with CSS easing. Storing springs as their own token type, and generating a linear() approximation for CSS, keeps each platform honest instead of forcing native code into beziers.
Reduced motion has different switches. The web reads prefers-reduced-motion; iOS exposes a Reduce Motion setting; Android exposes a system setting that can remove animations. Each platform’s output needs a reduced variant tied to its own switch, generated from the same reduced values in the source.
Step-by-step resolution
Production code pattern
{
"motion": {
"enter": {
"duration": { "type": "duration", "value": 240, "reduced": 100 },
"easing": { "type": "cubicBezier", "value": [0.2, 0.8, 0.2, 1] }
},
"exit": {
"duration": { "type": "duration", "value": 160, "reduced": 100 },
"easing": { "type": "cubicBezier", "value": [0.4, 0, 1, 1] }
},
"sheet": {
"spring": { "type": "spring", "stiffness": 380, "damping": 32, "mass": 1 }
}
}
}
// build/motion-css.js — a minimal generator.
import tokens from '../motion.tokens.json' with { type: 'json' };
import { springToLinear } from './spring-to-linear.js'; // samples the spring into linear() stops
const lines = [':root {'];
const reduced = ['@media (prefers-reduced-motion: reduce) {', ' :root {'];
for (const [role, t] of Object.entries(tokens.motion)) {
if (t.duration) {
lines.push(` --motion-${role}-duration: ${t.duration.value}ms;`);
if (t.duration.reduced != null) reduced.push(` --motion-${role}-duration: ${t.duration.reduced}ms;`);
}
if (t.easing) lines.push(` --motion-${role}-easing: cubic-bezier(${t.easing.value.join(', ')});`);
if (t.spring) {
const { easing, durationMs } = springToLinear(t.spring);
lines.push(` --motion-${role}-easing: ${easing};`);
lines.push(` --motion-${role}-duration: ${durationMs}ms;`);
}
}
lines.push('}'); reduced.push(' }', '}');
process.stdout.write([...lines, ...reduced].join('\n') + '\n');
/* Generated output (excerpt) */
:root {
--motion-enter-duration: 240ms;
--motion-enter-easing: cubic-bezier(0.2, 0.8, 0.2, 1);
--motion-exit-duration: 160ms;
--motion-exit-easing: cubic-bezier(0.4, 0, 1, 1);
}
@media (prefers-reduced-motion: reduce) {
:root {
--motion-enter-duration: 100ms;
--motion-exit-duration: 100ms;
}
}
Rendering Impact: none. The generator runs at build time; the web receives ordinary custom properties that are resolved when styles are computed.
The native generators follow the same loop, emitting, for example, a Swift UICubicTimingParameters(controlPoint1:controlPoint2:) for each cubic-bezier token and a Kotlin CubicBezierEasing(0.2f, 0.8f, 0.2f, 1f) for Compose. The important property is that no platform hand-copies numbers.
Where parity should stop
Identical numbers do not guarantee identical feel, and forcing exact parity can make an app feel foreign on its own platform. System components — navigation pushes, sheets presented by the OS, keyboard appearance — follow platform conventions that users expect, and overriding them with web-derived curves often makes the app feel less polished. Apply shared tokens to the product’s own components and branded moments, and let system transitions stay native. Record which components are intentionally platform-specific in the token documentation, so parity reviews do not flag them as bugs.
Verification checklist
Constraints and trade-offs
linear()approximations of springs are fixed at build time and cannot respond to interruption velocity as native springs do.- Platform system settings can scale durations; parity tests should run with default settings.
- Generators are code to maintain; keep them small and tested.
- Designers may want per-platform tuning; allow optional platform overrides in the source rather than forks.
- Older browsers without
linear()need a cubic-bezier fallback for spring tokens.
Frequently asked questions
Can the same cubic-bezier values be used on iOS and Android?
Yes. CSS cubic-bezier, iOS cubic timing parameters and Android path interpolators all use the same two control points.
How do I share spring animations with the web?
Store the spring’s physical parameters as tokens, and generate a CSS linear() easing that samples the spring’s motion over a computed duration.
Should web and native motion be identical?
For the product’s own components and brand moments, close parity helps. System-provided transitions should usually keep platform conventions.
How is reduced motion handled across platforms?
Store the reduced values once in the source and have each generator attach them to the platform’s own reduced-motion setting.
Related
- Motion Design Systems & Tokens — the parent topic
- Building a Duration and Easing Token Scale — the values being shared
- linear() Easing Function for Custom Curves — the CSS side of spring tokens