Skip to content

The Motion Grammar

Motion is the only channel that can express change: where something came from, what caused it, whether it is still the same object. Static design says what things are; motion says what just happened. Anything animated that says nothing is a delay you imposed.

The governing question is never "how long" but "what does this motion tell the user that the start and end frames do not?" Only four answers count:

  • Origin and destination. A dialog scaling from its trigger is that button expanded; one appearing dead-centre forces a re-parse.
  • Causality. Motion on the frame the user acts binds effect to cause. Past ~100ms the two read as separate events.
  • Continuity. Moving elements through a reorder preserves object identity; cutting destroys it.
  • Feedback. Input received, or refused.

Fail all four and delete it. That test alone removes most motion in generated interfaces.

The eight intents

Every animation maps to one of eight semantic intents, each with prescribed duration, curve, and property defaults.

IntentDurationCurveProperty
enter200–300msdecelerateopacity + translate
exit120–180msaccelerateopacity + translate
transform250–350msease-in-outtransform
respond60–120msease-outscale / opacity
attract500ms, max 3 cyclesease-in-outtransform
occupyloopinglineartransform
affirm300–400msovershootscale
reject350ms, 2 cyclesdecayingtranslateX 6px

occupy alone may loop forever, and only while an operation is outstanding.

Easing is a physical story

Entrances deceleratecubic-bezier(0.16, 1, 0.3, 1). The element arrives carrying momentum and settles where the user must read it.

Exits acceleratecubic-bezier(0.4, 0, 1, 1). It is departing, so there is nothing to read.

On-screen transforms use bothcubic-bezier(0.4, 0, 0.2, 1).

Never linear outside loops and gesture-tracked motion. Zero-then-infinite acceleration matches no physical event.

The commonest motion bug in shipped UI is an entrance curve on an exit. Run exits at 60–70% of the matching entrance duration.

Duration comes from perception

Below ~100ms a change reads as instantaneous: the budget for press and hover responses.

At 200–300ms the eye can track an object: entrances and transforms.

Past ~400ms motion stops being information and becomes a wait. Past 600ms the user attributes the delay to their device.

The motion contract enforces a 600ms ceiling. This is where the perception curve breaks.

The MCP tools

resolve_motion computes duration and easing from a semantic intent:

resolve_motion(intent: "enter", distance: "far")
→ duration: 280ms
  easing: "cubic-bezier(0.16, 1, 0.3, 1)"
  css: "transition: opacity 280ms ..., transform 280ms ...;"

check_animation_property says whether a property is composited or forces layout:

check_animation_property(property: "width")
→ composited: false
  forcesLayout: true
  recommendation: "Animate transform: scaleX() instead."

compute_stagger produces per-element delays with automatic compression for long lists:

compute_stagger(count: 12, baseDelay: 40)
→ delays: [0, 40, 80, 120, 160, 180, 200, 210, 220, 225, 230, 235]
  note: "Compressed after item 6 so the list completes in under 300ms."

prefers-reduced-motion

The motion contract requires a prefers-reduced-motion guard by default. The correct fallback is opacity-only transitions, which preserve causality without vestibular stimulation.

css
@media (prefers-reduced-motion: reduce) {
  .animated {
    transition: opacity 200ms ease;
    /* no transform */
  }
}

Using the @vishwakarma/motion package

tsx
import { Reveal, RevealStyles, useMotion } from '@vishwakarma/motion'

export function Layout({ children }) {
  return (
    <html>
      <head><RevealStyles /></head>
      <body>{children}</body>
    </html>
  )
}

export function Features({ items }) {
  return items.map((item, i) => (
    <Reveal key={item.id} from="below" distance="short" delay={i * 40}>
      {item.title}
    </Reveal>
  ))
}

RevealStyles emits a small blocking script that arms the reveal CSS. Without it, elements appear without animating — the correct failure mode.

Released under the Apache 2.0 License.