Skip to content

Feature: motion tokens (duration, easing) with prefers-reduced-motion fallback #47

Description

@oknemixam

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 ## Motion markdown section.

Precedent

  • W3C DTCG 2025.10 does not cover motion tokens (confirmed by reading the current draft)
  • Material Design 3 ships duration + easing tokens (md.motion.duration.short1 = 50ms, etc.)
  • IBM Carbon, Fluent UI, Shopify Polaris all publish motion tokens
  • Radix UI and Tailwind ship motion utilities but no formal tokens
  • Missing link: a format that lets a design system publish motion tokens as first-class entries that an agent or exporter can read

Proposed schema

motion:
  duration:
    fast: 150ms
    medium: 300ms
    slow: 600ms
  easing:
    standard:    "cubic-bezier(0.2, 0, 0, 1)"
    emphasized:  "cubic-bezier(0.2, 0, 0, 1.5)"
    decelerate:  "cubic-bezier(0, 0, 0, 1)"
  prefers-reduced-motion-fallback:
    medium: 100ms    # 300ms collapses to 100ms
    slow:   100ms    # 600ms collapses to 100ms

Parallel to existing spacing / rounded maps. 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 in prefers-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.md bridge:

  • motion.ts ~100 lines — parse, validate
  • test-motion.ts 27/27 passing
  • Integrates cleanly as an additive motion: 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:

  1. Spec-only (just adopt the schema into docs/spec.md)
  2. Spec + linter rule (adds the rule to DEFAULT_RULES)
  3. Spec + linter + exporter (Tailwind config theme.transitionDuration + theme.transitionTimingFunction)

Prior art for reference

Activity

  1. davideast commented on May 1, 2026

    @davideast
    Collaborator

    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.

  2. AndrewOCC commented on May 18, 2026

    @AndrewOCC

    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 linear and 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)'
  3. jeanpierreboutros-lang commented on May 21, 2026

    @jeanpierreboutros-lang

    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 official motion: 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; easing is 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
    inside motion.duration.{fast/medium/slow}. Three options:

    1. Allow flat motion: keys alongside the structured duration/easing
      sub-objects.
      motion.crossfade: 100ms lives next to
      motion.duration.medium: 300ms. Lint rule could allow either form.
    2. Add an explicit motion.time-constants: sub-object for non-
      animation time tokens, parallel to motion.duration and
      motion.easing. Makes the intent explicit.
    3. Add a dedicated motion.units: field (defaulting to ms) so
      integer values like 1 for our recall-ramp-blocks are
      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-compat rule (warn when duration > 200 ms
    without a prefers-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.easing only. Tokens under a hypothetical
    motion.time-constants would 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
    existing motion: key fine because the parser tolerates unknown
    top-level groups.

  4. kjooncho commented on Jul 28, 2026

    @kjooncho

    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) and cubicBezier (§8.6) — but physics-based easing has no representation anywhere except linear() 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-fallback shortens 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. If motion-reduce-compat warns 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.

  5. inkxel commented on Aug 22, 2026

    @inkxel

    @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-compat directly: 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/slow can'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.

  6. kjooncho commented on Aug 29, 2026

    @kjooncho

    @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 uses at 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. If motion: 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 reduced is: 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 prose sequence field; 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 intensity scalar; 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions