Motion Recipe Contract
Recipes are the semantic layer between design intent and runtime animation. They name what a motion means (enter.fade-up, state.error), not how to draw it. The runtime decides how to draw it.
The 4 layers
Motion in @preset/motion flows through four layers. Each layer is honest about what it knows and what it does not.
| Layer | What it owns | Examples |
|---|---|---|
| Primitives | Atomic WAAPI animations | fade-in, slide-up, typewriter |
| Recipes | Semantic motion tokens (this contract) | enter.fade-up, state.error |
| Compositions | How recipes combine in a single surface | A modal that slides in + its content fades up |
| Interrupts | What happens when motion is cancelled, reversed, or resumed | Snap to target, reverse time, hold |
Primitives are the engine layer. Recipes are the semantic layer that callers actually pick from. Compositions and interrupts are policies layered on top.
This page documents the Recipe layer.
Recipe shape
Every recipe is described entirely in JSON. The source of truth lives in getfndd/animatic at catalog/motion-recipes.json and is synced into Preset via npm run sync:motion-recipes. The schema lives next to it at catalog/motion-recipes.schema.json.
A recipe has six required fields plus optional tags:
| Field | Type | Purpose |
|---|---|---|
id | category.intent string | Stable semantic name. enter.fade-up, state.error. |
semantic_intent | string | One sentence describing when this recipe is appropriate. |
appropriate_contexts | string[] | Surface types where it belongs (card, modal-body, toast). |
runtime_scope | ('framer-motion' | 'css-subset')[] | Runtimes that can render it. v0 recipes ship in both. |
tokens | duration / easing / from / to | The actual animation tokens. CSS variable refs encouraged. |
interrupt_contract | on_cancel / on_reverse / on_resume? | What happens when motion is interrupted. |
accessibility_fallback | reduced_motion variant + differentiation | Required. See below. |
tags | string[] | Optional hints for retrieval and KG ingest. |
The tokens field carries duration, easing, from, to, and optional repeat + repeat_type. Durations and easings are CSS variable refs (var(--duration-quick)) so the design system owns the actual numbers. useMotionRecipe resolves those via getComputedStyle(document.documentElement) at runtime.
The v0 catalog
Six recipes ship in v0. Five are dual-runtime (Framer Motion + CSS); state.error is Framer-only because its reduced-motion fallback uses border_color, which sits outside the css-subset's opacity+transform contract. See Runtime Matrix for the full split.
| ID | Intent | Use it for |
|---|---|---|
enter.fade-up | Element enters on mount or scroll-in | Cards, sections, modal bodies, list items |
enter.fade-in | Element cross-fades into view | Overlays, backdrops, images, badges |
exit.fade-down | Element leaves the viewport | Cards, modal bodies, toasts, list items |
enter.slide-right | Element slides in from the left | Side panels, drawers, lateral list items |
attention.pulse | Subtle scale ping on changed data | Badges, counters, notification dots, status chips |
state.error | Shake + error feedback | Inputs, buttons, form fields, inline validation |
The route.drill-forward and route.drill-back recipes were proposed for v0 but deferred to v0.1 because their spring easing has no portable CSS equivalent. v0 ships with one Framer-only recipe already (state.error, for border_color reasons), so the precedent for honest runtime mixing is in place when the spring-only recipes land.
Reduced motion is first-class
Every recipe carries a required accessibility_fallback.reduced_motion block. There is no opt-out. The schema rejects recipes without one.
The fallback has three required fields: from, to, and differentiation. The differentiation is a sentence explaining how the reduced-motion variant still communicates the recipe's intent. A silent fallback (animation simply doesn't play) is rejected — every state must remain visible.
Two examples:
enter.fade-upreduces to opacity-only fade. Translation is removed so vestibular users are not affected.state.errorreduces from horizontal shake to a red border swap. The error state stays visually communicated; a silent fallback would hide the rejection.
useMotionRecipe() reads useReducedMotion() from Framer Motion and swaps automatically. The CSS path emits a @media (prefers-reduced-motion: reduce) block that points the base class at the reduced keyframe.
Interrupt contracts
Animation interrupts are a real source of jank. Recipes name their behaviour explicitly.
| Field | Values |
|---|---|
on_cancel | snap-to-target, hold-current, snap-to-origin |
on_reverse | reverse-time, restart-reversed, snap-to-origin |
on_resume (optional) | continue, restart |
Most v0 recipes use snap-to-target on cancel and reverse-time on reverse — clean entrance behaviour. The looping recipes (attention.pulse, state.error) snap-to-origin on cancel because freezing mid-pulse looks broken.
The contract is documented in the recipe and threaded into Framer transitions where the props map cleanly. Strict enforcement requires AnimationControls and is a v0.1 concern.
Choosing a recipe
A short decision rubric:
| Question | Recipe |
|---|---|
| Element entering on mount/scroll? | enter.fade-up (default) or enter.fade-in (overlay/backdrop) |
| Element entering from a side? | enter.slide-right |
| Element leaving? | exit.fade-down |
| Drawing attention to a change? | attention.pulse |
| Communicating rejection on a form field? | state.error |
If none of these match, you probably want a primitive (@preset/motion's engine layer) directly, or you need a new recipe. Adding a recipe goes through the animatic catalog — propose it as a JSON addition to catalog/motion-recipes.json with a passing schema check.