Motion Runtime Matrix
@preset/motion recipes target two runtimes: Framer Motion and a CSS subset. In v0, five of six recipes run in both — state.error is Framer-only because its reduced-motion fallback uses border_color, which sits outside the opacity+transform contract. The matrix below documents what each runtime can express, which features are intentionally excluded from one side, and how the same recipe stays semantically equivalent across both when both apply.
Why two runtimes
Different surfaces want different things. React apps with rich gesture state want Framer Motion. Static site pages, email-style HTML, and React Server Components want plain CSS animations. Forcing a single runtime would either bloat static pages with React-Motion overhead or strip dynamic apps of expressiveness.
Recipes resolve the tension at the semantic layer. A recipe says what it does (enter.fade-up), not how it does it. Each runtime renders the same intent in its own idiom.
The matrix
| Capability | Framer Motion | CSS subset | Notes |
|---|---|---|---|
| Opacity transition | ✓ | ✓ | Identical visual result |
| Translate (x, y) | ✓ | ✓ | CSS uses translate*() transforms |
| Scale | ✓ | ✓ | |
| Rotate | ✓ | ✓ | |
| Border-color swap | ✓ | ✓ | Used in reduced-motion fallback for state.error |
| Repeat (with mirror) | ✓ | ✓ | CSS uses animation-iteration-count + animation-direction: alternate |
| Cubic-bezier easing | ✓ | ✓ | |
| Spring physics | ✓ | ✗ | Not expressible as a single CSS animation duration |
| Filter (blur, brightness) | ✓ | ✗ | Excluded from v0 by JSON schema |
| Gesture-driven motion | ✓ | ✗ | Drag, hover-while-tap, etc. — Framer-only by design |
Layout animation (layoutId) | ✓ | ✗ | Framer-only |
| Interrupt behaviour | partial | minimal | Documented in recipe; enforcement is Framer-controlled |
| Reduced-motion fallback | ✓ | ✓ | Both runtimes honour it (see below) |
Five of the six v0 recipes use only the rows where both columns show ✓. The sixth, state.error, uses border_color in its reduced-motion fallback — outside the css-subset contract — and is Framer-only. This is the runtime matrix in action: a recipe declares its runtime_scope honestly, and generateRecipeCss() skips recipes that fall outside the subset rather than emit broken CSS.
The v0 split
| Recipe | framer-motion | css-subset |
|---|---|---|
enter.fade-up | ✓ | ✓ |
enter.fade-in | ✓ | ✓ |
exit.fade-down | ✓ | ✓ |
enter.slide-right | ✓ | ✓ |
attention.pulse | ✓ | ✓ |
state.error | ✓ | — |
state.error's reduced-motion fallback swaps shake for a red border (border_color: var(--color-error)). That preserves the error signal — a silent fallback would hide the rejection. But border_color is not in the opacity+transform subset that generateRecipeCss() targets, so the recipe declares runtime_scope: ["framer-motion"] and is skipped from the CSS bundle. Marketing surfaces and other CSS-rendered contexts that need an error indication should layer their own styling rather than rely on a recipe.
Why springs are not portable
CSS animations declare a fixed duration. Springs do not have a fixed duration — their length depends on stiffness, damping, mass, and the distance being travelled. The closest CSS approximation is a tuned cubic-bezier, but it loses the velocity-aware behaviour that makes springs feel right.
This is why the proposed route.drill-forward and route.drill-back recipes were deferred to v0.1. They both required spring easing for the page-transition feel, and state.error's framer-only status had not yet shown that the catalog could honestly mix runtime targets. v0 now ships with one Framer-only recipe (state.error, for border_color reasons) — when v0.1 lands spring-only recipes, the precedent and the matrix already accommodate them.
The validator (Rand, ANI-138) will warn when a CSS-target consumer requests a Framer-only recipe — the same rule that enforces this for state.error will cover the spring-only recipes when they ship.
CSS subset rules
The css-subset runtime accepts a strict subset of recipe features. The JSON schema enforces this at authoring time:
- Keyframes may only reference
opacity,x,y,scale,rotate,border_color. Nofilter, no arbitrary CSS properties. - Easing must be a cubic-bezier curve or a CSS-named easing (
linear,ease-in,ease-out,ease-in-out). repeat_type: 'mirror'maps toanimation-direction: alternate.'loop'and'reverse'map directly.- Spring easing is rejected — the recipe is filtered out of the CSS bundle entirely.
The CSS bundle is generated via generateRecipeCss(listRecipes()) at build time. Recipes that fail the subset rules are skipped silently with their IDs returned by listCssIncompatibleRecipes() for build-time logging.
Reduced motion across runtimes
Both runtimes honour accessibility_fallback.reduced_motion automatically.
Framer path. useMotionRecipe() calls Framer's useReducedMotion(). When true, the hook returns the fallback keyframes instead of the recipe's tokens.from / tokens.to. Consumers spreading {...result} on a <motion.*> element get the fallback transparently.
CSS path. generateRecipeCss() emits two keyframe sets per recipe (motion-enter-fade-up and motion-enter-fade-up-reduced) plus a media query that swaps the base class to the reduced variant when prefers-reduced-motion: reduce is set. Consumers who prefer explicit control can target .motion-enter-fade-up--reduced directly.
The differentiation string from the recipe is documentation, not behaviour. It explains how the fallback still communicates the intent. Recipe authors must write a meaningful one — silent fallbacks are explicitly rejected.
Future runtimes
The runtime list is currently 'framer-motion' | 'css-subset'. Two candidates were considered for v0 and deferred:
- React Spring — would compete with Framer for the JS-runtime slot. No clear win for our recipes; revisit if we adopt React Spring elsewhere in the codebase.
- GSAP — overlaps with both Framer (gesture/layout) and Lottie (timeline-heavy). Worth revisiting if we ship a timeline-recipe layer.
- Lottie — different problem space (vector/raster animations from After Effects). Tracked as a v1 candidate when designers want to ship hand-keyed motion through the same recipe contract.
Adding a runtime is a contract change: the JSON schema's runtime_scope enum extends, the validator (Rand) gains a check, and existing recipes annotate which new runtime they target. Until then, this page describes everything the recipe layer can do.