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.

One source, three outputsPlatform generators translate the same numbers into native syntax.One source, three outputsmotion.tokens.jsonms + control pointsGeneratorbuild stepCSS variableswebSwiftconstantsiOSKotlinconstantsAndroid
Platform generators translate the same numbers into native syntax.

Step-by-step resolution

Setting up shared motion tokensNumbers in the source, syntax in the generators.Setting up shared motion tokens1Move all motion values into a JSON file with typed entries.duration, cubicBezier, spring2Include a reduced value for each semantic role.Reduced motion defined once3Write a CSS generator that emits custom properties and a reduced-motion media block.Web output4Write native generators that emit constants for timing parameters and springs.iOS and Android output5Publish outputs as versioned packages consumed by each app.Changes propagate with a version bump6Build a reference screen on each platform to compare motion side by side.
Numbers in the source, syntax in the generators.

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.

How each token type maps to each platformBeziers translate exactly; springs need an approximation on the web.How each token type maps to each platformWeb (CSS)iOSAndroidDurationms valueTimeInterval secondsms valueCubic beziercubic-bezier()UICubicTimingParametersPathInterpolator /CubicBezierEasingSpringlinear() approximationNative springNative springReduced motionprefers-reduced-motionReduce Motion settingSystem animation setting
Beziers translate exactly; springs need an approximation on the web.

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.