Skip to main content

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.

LayerWhat it ownsExamples
PrimitivesAtomic WAAPI animationsfade-in, slide-up, typewriter
RecipesSemantic motion tokens (this contract)enter.fade-up, state.error
CompositionsHow recipes combine in a single surfaceA modal that slides in + its content fades up
InterruptsWhat happens when motion is cancelled, reversed, or resumedSnap 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:

FieldTypePurpose
idcategory.intent stringStable semantic name. enter.fade-up, state.error.
semantic_intentstringOne sentence describing when this recipe is appropriate.
appropriate_contextsstring[]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.
tokensduration / easing / from / toThe actual animation tokens. CSS variable refs encouraged.
interrupt_contracton_cancel / on_reverse / on_resume?What happens when motion is interrupted.
accessibility_fallbackreduced_motion variant + differentiationRequired. See below.
tagsstring[]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.

IDIntentUse it for
enter.fade-upElement enters on mount or scroll-inCards, sections, modal bodies, list items
enter.fade-inElement cross-fades into viewOverlays, backdrops, images, badges
exit.fade-downElement leaves the viewportCards, modal bodies, toasts, list items
enter.slide-rightElement slides in from the leftSide panels, drawers, lateral list items
attention.pulseSubtle scale ping on changed dataBadges, counters, notification dots, status chips
state.errorShake + error feedbackInputs, 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-up reduces to opacity-only fade. Translation is removed so vestibular users are not affected.
  • state.error reduces 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.

FieldValues
on_cancelsnap-to-target, hold-current, snap-to-origin
on_reversereverse-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:

QuestionRecipe
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.

Was this page helpful?