Repository navigation
Feature: motion tokens (duration, easing) with prefers-reduced-motion fallback #47
Description
Activity
Thank you @oknemixam!
This is an interesting idea for an addition. I think this is where we need to make sure that DESIGN.md doesn't hard code map to CSS or any other rendering syntax. DESIGN.md's north star is to capture design intent which is a mixture of prose and tokens. Once we start introducing more complex areas such as animation the token surface needs to be strongly considered and evaluated.
For now you can always add these as custom tokens with a custom section. If you currently do this and have generated designs from them, please share what you've come up with. I'd love to see it in action.
Seconding the desire for duration and easing tokens; in my team's DESIGN.md we've added these via a custom section below, which I've shared below 😄
The DTCG's spec includes examples for how beziers and durations may be represented without hard coding CSS values - but spring animations use
linearand aren't in the spec and may be more challenging to represent in a standardised way:motion: duration: 'instant': '0ms' 'xxshort': '50ms' 'xshort': '100ms' 'short': '150ms' 'medium': '200ms' 'long': '250ms' 'xlong': '400ms' 'xxlong': '600ms' easing: 'in-practical': 'cubic-bezier(0.6, 0, 0.8, 0.6)' 'inout-bold': 'cubic-bezier(0.4, 0, 0, 1)' 'out-practical': 'cubic-bezier(0.4, 1, 0.6, 1)' 'out-bold': 'cubic-bezier(0, 0.4, 0, 1)' 'spring': 'linear(0, 0.021, 0.058, 0.107, 0.164, 0.227, 0.292, 0.359, 0.425, 0.49, 0.552, 0.61, 0.664, 0.714, 0.759, 0.8, 0.837, 0.869, 0.898, 0.922, 0.943, 0.961, 0.976, 0.988, 0.998, 1.006, 1.013, 1.017, 1.02, 1.023, 1.024, 1.024, 1.024, 1.024, 1.023, 1.022, 1.02, 1.019, 1.017, 1.015, 1.014, 1.012, 1.011, 1.009, 1.008, 1.007, 1.006, 1.005, 1.004, 1.003, 1.002, 1.002, 1.001, 1.001, 1.001, 1, 1, 1, 1, 1, 0.999, 0.999, 0.999, 0.999, 1)'
Strong +1 on this proposal. Filing a real-world implementation note and one
proposed scope extension worth thinking about before the schema lands.Implementation reference
We shipped a
motion:block in our project's DESIGN.md
two days ago as a forward-compatible non-standard extension, with the
explicit goal that any officialmotion:schema from this repo should
be a strict superset of what we shipped. Happy to migrate if/when the
official shape lands.Our front matter (live in the file):
motion: # ── Audio-domain ── crossfade: 100ms # A/B chain crossfade (equal-power) recall-ramp-blocks: 1 # snapshot recall gain ramp, in audio blocks meter-fast-tau: 70ms # fast meter envelope time constant meter-slow-tau: 250ms # slow meter envelope time constant peak-hold: 1500ms # peak indicator dwell before fall # ── UI-domain ── hover: 120ms # button hover / click feedback engine-reattach: 60ms # device-dialog close -> audio re-attach defer web-broadcast: 200ms # iPad companion state push interval (5 Hz)
Proposed scope extension: non-animation motion tokens
The schema in this issue (
duration: { fast/medium/slow }+easing+
prefers-reduced-motion-fallback) assumes the consumer is CSS-style
animation. That maps cleanly to web apps and is the right primary case.But it doesn't fit a class of native/audio/realtime apps where motion
tokens are semantic time constants, not animation curves. Concrete
examples from our shipped DESIGN.md:- Audio crossfade (100 ms equal-power) — there's no "easing" in the
CSS sense; the equal-power curve is mathematically fixed
(sin(phase) / cos(phase)). The number is a duration, but the
rendering pipeline is the audio thread, not a render loop. - Meter envelope tau (70 ms / 250 ms) — exponential decay
coefficients for VU-style meters. Strictly a time constant for an
IIR;easingis undefined here. - Recall ramp blocks (1) — measured in audio blocks, not ms.
The actual wall-clock duration depends on the sample rate and buffer
size at runtime. - Peak hold (1500 ms) — display dwell. Closest to a CSS-style
duration, but again no easing applies.
These don't break the proposed schema; they just don't fit cleanly
insidemotion.duration.{fast/medium/slow}. Three options:- Allow flat
motion:keys alongside the structuredduration/easing
sub-objects.motion.crossfade: 100mslives next to
motion.duration.medium: 300ms. Lint rule could allow either form. - Add an explicit
motion.time-constants:sub-object for non-
animation time tokens, parallel tomotion.durationand
motion.easing. Makes the intent explicit. - Add a dedicated
motion.units:field (defaulting toms) so
integer values like1for ourrecall-ramp-blocksare
self-describing as audio blocks vs. ms vs. seconds.
I'd vote (2) — explicit naming for the two domains.
time-constants
also implicitly tells an agent "these are physical/temporal values
for the runtime, not transition curves for the renderer".Lint-rule note
The proposed
motion-reduce-compatrule (warn when duration > 200 ms
without aprefers-reduced-motion-fallback) is web-only. For audio
tokens, the equivalent check would be WCAG 2.3.1 / 2.3.2 — content
that flashes more than 3 Hz must not exceed certain area limits, but
that's a different category entirely (we're not flashing screens).Suggestion: scope the proposed lint rule to keys under
motion.duration+motion.easingonly. Tokens under a hypothetical
motion.time-constantswould be exempt by convention.
Adoption story for our case: the moment the official schema lands we'd
migrate the audio tokens to whichever shape the spec adopts. Until
then, our consumers (Claude Code in our repo, primarily) read the
existingmotion:key fine because the parser tolerates unknown
top-level groups.- Audio crossfade (100 ms equal-power) — there's no "easing" in the
Real-world implementation notes from a personal motion design system, since @davideast asked for shipped examples. Context: I'm a UX motion designer; the token values below are back-derived from the past 11 years of production motion work (n=51,271 durations, 61,422 bezier curves from Lottie/AE archives), formalized as DTCG-2025.10-compatible JSON and consumed by coding agents (Claude Code).
Four observations that may be useful before the schema lands:
1. Springs are the actual standards gap. As @AndrewOCC noted, DTCG 2025.10 already covers
duration(§8.5) andcubicBezier(§8.6) — but physics-based easing has no representation anywhere exceptlinear()approximations, which lose the model (you can't retune what you can't read). We ship a custom type:"spring": { "snappy": { "$type": "lms.spring", "$value": { "response": 0.3, "damping": 0.7 } } }
Whatever DESIGN.md decides for
motion:, an explicit convention for spring parameters (even just "custom$type, these two fields") would close a gap neither DTCG nor CSS closes today.2. Reduced-motion fallbacks are behavioral, not (only) temporal. The proposed
prefers-reduced-motion-fallbackshortens durations. In our system, after mapping 34 component recipes, almost no fallback turned out to be "same animation, shorter" — they are qualitative substitutions:"opacity-only","static block","spring→ease","crossfade","instant". Suggestion: allow the fallback value to be either a duration or a behavior keyword. A duration-only map under-specifies what agents should actually generate for reduced-motion users.3. One-way transitions and indefinite loops are different axes. Our archive shows transition durations clustering at 90–600ms while loop periods (spinners, shimmer) live at 1,100–1,500ms+ (Lottie loop medians 2,000–7,500ms across years). Loops have a
period, a cycle shape (closed vs open), and an end condition — not a duration. Ifmotion-reduce-compatwarns on every value >200ms, a 1,100ms spinner period false-positives: its correct reduced path is behavioral ("keep rotation, drop extras"), not shortening. Suggestion: scope the lint to transition durations, and leave room for a loop/ambient sub-object.4. Intent layer, in practice. Re: the north star of capturing design intent — our top layer maps intent to primitives (
enter,exit,press,celebrate,numberUpdate…), and agents consume that, not raw ms values. This has been the single highest-leverage layer for generation quality. Happy to share the full schema if useful.@davideast — you asked to see motion tokens shipped as a custom section and generated from. Here's ours. Posting it because what came out doesn't look much like the schema in this issue.
(Also flagging #156, open on roughly this topic but with no detail in it yet.)
Where the tokens come from
A script walks a brand's After Effects project and reads every keyframe — timing, value, easing handles — then compiles them into a motion file. Then we generate from it: rebuild a composition in Remotion from the token file alone, never opening the source project, and diff it frame by frame against After Effects' own evaluation.
Latest run: 212 compositions, ~2,600 animated properties, ~2,500 eased segments. On the rebuilt composition five of seven properties reproduce the source exactly, worst 0.235% of its own range. Watching the two side by side I couldn't pick which was which, though I knew what I was looking for.
So it works as a generation source. Four things about the tokens surprised me.
1. The durations don't reduce to a scale. Four of them cover about a third of the library, so
duration: {fast, medium, slow}is real and worth having. The rest don't ladder at all. They're tied to specific named moves, and folding those into a scale loses the thing that made them specific.2. About a quarter of the easing is deliberately linear — ambient loops, rotating gradients, idle spinners. Motion that never starts or stops, where a linear rate is correct and easing it would be a bug. Same shape as @kjooncho's point 3 from a different corpus, and it bites
motion-reduce-compatdirectly: those loops run for seconds, so a rule warning on anything over 200ms fires on all of them, and shortening is the wrong fix. Their reduced path is behavioural — keep the rotation, drop the extras. Worth scoping the lint to transition durations, as they suggested.3. The signature is in the ending. Nearly 40% of eased segments do the same thing: still moving at 95%+ of travel, then stopping flat. Different curves, same second control point. That's the part a designer would point at and call "this brand," and
fast/medium/slowcan't hold it. You'd have to keep the curves themselves.4. Springs are 4 of ~800 expression-driven properties. Correcting my own number — I first wrote "zero" here, and that was wrong. It was wrong because I sampled the head of a regex match list, saw the same false positive in all of them, and generalised to the tail. There are four. All of them are the same canonical After Effects damped-oscillation rig, and all four are on one gesture: a checkmark's celebratory pop. After Effects has no spring primitive, so where this library wants one it hand-builds it — once, for one moment. Everything else, including every overshoot, is authored bezier control points with y > 1, tuned per move.
0.5% makes the point better than "zero" did, I think. The spring conversation here and in DTCG #429 is drawn almost entirely from web and product UI, where springs really are the default. In broadcast and brand motion a spring is a special-case garnish, not the substrate. If
motion:ends up spring-first, worth keeping bezier first-class rather than the fallback.The one that might be a schema question
@kjooncho's fourth point — agents consume the intent layer (
enter,exit,press,celebrate) rather than raw ms — matches what we see, and it's the part I'd most like to see land somewhere. Ours is a "signature moves" section written in prose: named moves, each pointing at the primitives it's built from. It's the section a person actually reads, and the least structured thing in the file.That may be what you meant about intent being a mixture of prose and tokens. If so, the interesting question for
motion:might be less about the primitive fields and more about whether a named move can reference them.Happy to share the compiler or the file shape. It's somebody's brand data so I can't post the values, but the structure isn't secret.
@inkxel — frame-diffing against the source evaluator is a much harder test than anything we run, and "zero springs" is the number I'd least have predicted. Answering your schema question, since it's the one I'd most like to see land too.
Yes — a named move can reference primitives, and the reference syntax needs nothing new: DTCG's alias string already does it. To be precise about what's standard and what isn't: the
{group.token}syntax and the primitives it points at are DTCG; the composite types holding the references below (lms.motion,lms.recipe) are our own extension vocabulary. DTCG standardizes what a reference points at, not the shape that holds it.// Tier 3 — named move (34 recipes) "component": { "state": { "enter": { "$type": "lms.recipe", "$value": { "uses": "{motion.semantic.enter}", "intensity": 45, "properties": "opacity 0→1, rise + slight scale", "interruptible": true, "reduced": "opacity-only" } }}}, // Tier 2 — intent (8) "semantic": { "enter": { "$type": "lms.motion", "$value": { "duration": "{motion.duration.standard}", "easing": "{motion.easing.decelerate}" } }}, // Tier 1 — primitives (DTCG types) "duration": { "standard": { "$type": "duration", "$value": { "value": 240, "unit": "ms" } } }, "easing": { "decelerate": { "$type": "cubicBezier", "$value": [0, 0, 0.38, 0.9] } }
(Full disclosure: our shipped file still writes durations as
"240ms"— it predates 2025.10's{value, unit}object form; the migration is mechanical.) Resolution isn't free either: Style Dictionary follows the aliases, but flattening the composite types into per-platform output took our own transforms. The format gives you the references; it doesn't give you both consumption modes for free.One honest wrinkle, and I think it's the actual schema question. I audited all 34 recipes before writing this: 11 reference a tier-2 entry (intent or ambient), 20 point straight at a primitive (a duration, a spring, an easing, a stagger), one references another recipe, and two have no
usesat all. The clean three-tier chain above is the ideal path, not an invariant. So in practice your question — "can a named move reference primitives?" — became "what may a reference slot point at?" We never constrained it, and it stayed useful. Ifmotion:lands with one rule — a named entry may alias any motion token; choreography stays prose — your library and ours both fit inside it.On prose being the least structured thing in the file — we hit that too, and split it in two, both attached to the token rather than living in a separate section:
properties/sequence(what moves — the part a person reads) and$extensions.lms.evidence(why this value — it travels with the token). An example that also covers the loop axis:"ambient": { "spin": { "$type": "lms.ambient", "$value": { "period": "{motion.duration.loopSpin}", "easing": "{motion.easing.linear}", "channel": "rotation", "cycle": "closed-loop", "until": "condition", "reduced": "keep rotation, drop extras" } }}
Note what
reducedis: a behavior, not a shorter number — same as the correct reduced path for your ambient loops. (We also keep a small capped set of signature moves as recipes of the same shape, choreography in a prosesequencefield; omitting the actual choreography here, since that's the part that carries a brand.)On bezier-first — agreed, with a correction to how I'd have put it before your data. Of our 8 intent entries, 6 lead with a
cubicBezier; 3 carry a spring, and in two of those (celebrate,numberUpdate) the spring is the curve model. So even a web/product corpus is bezier-led by count, but not spring-free — which is exactly why spring should be one optional primitive among peers rather than the organizing principle. The same schema then covers broadcast (zero springs) and product UI (some).On your point 3 — the tail carrying identity. Rather than match it with five authored tokens, I ran our archive the same way you ran yours. Same extraction as the corpus in my earlier comment — Lottie keyframe easings,
(o.x, o.y, i.x, i.y)rounded to 2dp. (Honest delta first: today's re-run sees 661 files / 54,944 segments; the 61,422 figure from July counted 695 files, and a disk migration since has lost some.) Calling a segment linear when both control points sit on the diagonal: 13.4% — same phenomenon as your quarter, though ours is mostly AE's default non-eased handles rather than deliberate loop authoring. Among the 47,580 eased segments:- One terminal control point — (0.35, 1.0) — covers 32.9%, against your ~40%. It's shared by 19 distinct start handles: literally "different curves, same second control point." (The signature in-out accounts for 11,235 of those; the other 18 are different approaches into the same landing.)
- Flat arrival is the norm across the corpus, not a property of the signature curve: 83.8% of eased segments end at exactly y₂ = 1.0 (85.4% at ≥0.95).
So two corpora with different authors, domains (broadcast/AE vs product-UX/Lottie), and extraction pipelines converge on the same shape: identity concentrated in the terminal control point, on top of a flat-arrival norm. That, more than any single curve, is the argument for keeping easing as a structured 4-number tuple — not because a CSS string loses the numbers (it doesn't), but because a renderer-neutral tuple is what a non-CSS consumer can preserve and compare. The script is trivial (~40 lines) — happy to share if anyone wants to point it at a third corpus.
On durations not laddering — same, and we stopped trying: seven one-way rungs (90→600ms), loop periods named separately off the scale (1,100/1,500ms), and amplitude not in the scale at all (recipes carry an
intensityscalar; the engine interpolates). A named move keeps its specificity through references plus prose, not through more rungs.The repo is private so I can't link it, but the shape isn't secret — the snippets above are the whole mechanism. @davideast, if it's useful when you evaluate the token surface: the one structural decision we'd advocate from shipping this is that single rule — named entries may alias any motion token; choreography stays prose. It's small, it's renderer-neutral, and it's the layer agents actually consume.
Summary
DESIGN.md currently has no way to express motion — duration, easing, transitions — which means agents generating code have to invent defaults every time. Proposing a
motion:frontmatter block and a## Motionmarkdown section.Precedent
md.motion.duration.short1= 50ms, etc.)Proposed schema
Parallel to existing
spacing/roundedmaps. Values are CSS-native (ms/s for duration,cubic-bezier(…)or named keyword for easing).Proposed lint rule
motion-reduce-compat— warn when a duration exceeds 200ms and no matching key exists inprefers-reduced-motion-fallback. Rationale: animations longer than roughly 200ms register as deliberate motion (rather than state transitions), which WCAG 2.2 + vestibular-sensitivity guidance says needs a reduced-motion path.Severity: warning. Authors who intentionally ship long animations without a fallback (brand showcase, hero, etc.) acknowledge the warning explicitly.
Implementation notes
Built and verified in our
@google/design.mdbridge:motion.ts~100 lines — parse, validatetest-motion.ts27/27 passingmotion:key (Google's parser silently drops unknown frontmatter keys, so this is backward-compatible with v0.1.1 until the spec lands)Happy to PR either as:
docs/spec.md)DEFAULT_RULES)theme.transitionDuration+theme.transitionTimingFunction)Prior art for reference