Installation
npx logic2b@next add motionThe motion engine ships the <Motion> primitive together with the recipe
maps and helpers. Each named preset is also installable on its own —
motion-fade,
motion-slide,
motion-scale and
motion-blur — and each one pulls in motion
automatically.
Usage
import { Motion } from "@/components/ui/motion"
<Motion preset="fade-up">
<Card>…</Card>
</Motion>
<Motion> plays its enter recipe once, on mount, and then gets out of the way.
It renders a div by default; pass asChild to animate the child element
directly instead of adding a wrapper node:
<Motion preset="scale" asChild>
<Card>…</Card>
</Motion>
Presets
| Preset | Effect |
|---|---|
fade |
Opacity 0 → 1 |
fade-up |
Fade while rising from below |
fade-down |
Fade while dropping from above |
fade-left |
Fade while entering from the right |
fade-right |
Fade while entering from the left |
scale |
Fade while zooming from 95% |
blur |
Fade while sharpening from an 8px blur |
Timing
Duration and delay are props — no arbitrary Tailwind class is generated at
runtime, so any value works. delay is the knob for staggering a list:
{items.map((item, i) => (
<Motion key={item.id} preset="fade-up" duration={500} delay={i * 80}>
<Item {...item} />
</Motion>
))}
The default is a 500ms ease-out enter. Override the easing with a className
(ease-in-out, ease-linear, …) — it merges over the default.
Hover
Pass a hover recipe to add an interaction on top of the enter animation:
<Motion preset="fade" hover="lift">
<Card>…</Card>
</Motion>
| Hover | Effect |
|---|---|
lift |
Rises 4px |
sink |
Presses down 2px |
scale |
Grows to 103% |
glow |
Raises the shadow |
Exit animations
<Motion> covers the mount case. For an element that also animates out — a
dialog, a popover, anything with a Radix data-[state] — reach for the recipe
maps and drive both states off data-state instead:
import {
motionEnterPresets,
motionExitPresets,
} from "@/components/ui/motion"
// Applied to a Radix content node:
<DialogContent
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=open]:fade-in data-[state=open]:zoom-in-95",
"data-[state=closed]:fade-out data-[state=closed]:zoom-out-95",
)}
/>
motionEnterPresets / motionExitPresets hold the exact class strings for
every preset if you’d rather look them up than hand-write the pair. The
motionEnter, motionExit and motionHover helpers build the full class
string (animation utility + timing + reduced-motion guard) for you.
Reduced motion
Every recipe degrades under prefers-reduced-motion: the enter animation is
dropped so content appears immediately (motion-reduce:animate-none) and hover
transitions are disabled (motion-reduce:transition-none). No extra wiring
needed.
The Framer Motion flavor
The presets above are deliberately CSS-only: they cost nothing at runtime and
handle the enter/hover cases most interfaces need. When you want spring physics,
shared-layout transitions or exit animations on plain (non-Radix) elements, drop
in the optional Framer Motion flavor of the same recipe. Install
framer-motion, then copy this alongside the CSS engine:
"use client"
import * as React from "react"
import { AnimatePresence, motion, type Transition } from "framer-motion"
const spring: Transition = { type: "spring", stiffness: 300, damping: 30 }
const variants = {
fade: { hidden: { opacity: 0 }, visible: { opacity: 1 } },
"fade-up": {
hidden: { opacity: 0, y: 12 },
visible: { opacity: 1, y: 0 },
},
scale: {
hidden: { opacity: 0, scale: 0.95 },
visible: { opacity: 1, scale: 1 },
},
} as const
export function MotionSpring({
preset = "fade-up",
show = true,
children,
}: {
preset?: keyof typeof variants
show?: boolean
children: React.ReactNode
}) {
return (
<AnimatePresence>
{show && (
<motion.div
initial="hidden"
animate="visible"
exit="hidden"
variants={variants[preset]}
transition={spring}
>
{children}
</motion.div>
)}
</AnimatePresence>
)
}
Same preset names, same tokens — spring feel and real exit animations when you need them, and nothing added to your bundle when you don’t.
Live playground
Edit the component props and inspect the exact JSX before copying it. The preview loads only when this section enters the viewport.
<Motion
preset="fade-up"
hover="lift"
duration={500}
delay={0}
>Animated content</Motion>API reference
Generated from the public TypeScript exports in src/ui/motion.tsx.
MotionPreset
TypeMotion presets — enter/exit/hover recipes expressed as token-driven class strings on top of `tw-animate-css` (zero runtime deps). The `<Motion>` primitive plays an enter recipe once on mount; the exported maps and helpers let you drop the same recipes onto anything else (a Radix `data-[state]` element, a hand-rolled transition). Timing rides the CSS custom properties tw-animate-css reads: - default duration/easing come from the static `duration-*`/`ease-*` classes baked into `motionEnter`, - the `duration`/`delay` props override them per-instance via `--tw-animation-duration` / `--tw-animation-delay` (no arbitrary Tailwind class is generated at runtime, so it works with any value). Everything degrades under `prefers-reduced-motion`: the enter animation is dropped (content shows immediately) and hover transitions are disabled.
type MotionPreset = | "fade" | "fade-up" | "fade-down" | "fade-left" | "fade-right" | "scale" | "blur"MotionHover
Typetype MotionHover = "lift" | "sink" | "scale" | "glow"motionEnterPresets
UtilityEnter recipes: tw-animate-css `animate-in` sets the keyframe `from` state, the element animates to its natural rendered state.
motionExitPresets
UtilityExit recipes: the mirror of each enter preset, for `animate-out` on a Radix `data-[state=closed]` element.
motionHoverPresets
UtilityHover recipes: plain Tailwind transitions, no keyframes.
motionEnter
UtilityBuild the class string for an enter recipe (plays on mount).
(preset: MotionPreset, className?: string): stringmotionExit
UtilityBuild the class string for an exit recipe (pair with `animate-out`).
(preset: MotionPreset, className?: string): stringmotionHover
UtilityBuild the class string for a hover recipe.
(hover: MotionHover, className?: string): stringMotionProps
Typeinterface MotionProps extends React.ComponentProps<"div"> { /** Enter recipe to play on mount. Default `"fade"`. */ preset?: MotionPreset /** Optional hover recipe applied to the same element. */ hover?: MotionHover /** Enter duration in milliseconds. Overrides the 500ms default. */ duration?: number /** Enter delay in milliseconds — the knob for staggering a list. */ delay?: number /** Merge props onto the single child instead of rendering a wrapper. */ asChild?: boolean }Motion
ComponentProps: MotionProps| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
preset | MotionPreset | No | "fade" | Enter recipe to play on mount. Default `"fade"`. |
hover | MotionHover | No | — | Optional hover recipe applied to the same element. |
duration | number | No | — | Enter duration in milliseconds. Overrides the 500ms default. |
delay | number | No | — | Enter delay in milliseconds — the knob for staggering a list. |
asChild | boolean | No | false | Merge props onto the single child instead of rendering a wrapper. |
Accessibility contract
Machine-readable behavior and consumer responsibilities for motion.
- Support
- Composition contract
- Pattern
- animated semantic wrapper
- Motion for React
Built-in semantics
- Preserves the semantics and focus behavior of the rendered child element.
Consumer responsibilities
- Respect prefers-reduced-motion and avoid animating focus, reading order or critical status changes.
Known limitations
- • Motion safety depends on the selected transition and consumer composition.