componentmotion
Motion primitives
FacadeMotionProvider, FadeIn, Reveal, Stagger and Collapse. Opacity and transform only, with reduced motion honoured.
Preview
Installation
pnpm dlx shadcn@latest add https://facadeui.dev/r/motion-primitives.jsonPulls in motion@^13.4.0, utils. The CLI installs them for you.
Usage
The exact source of the preview above.
"use client"
import { FadeIn } from "@registry/motion/fade-in"
import { Reveal } from "@registry/motion/reveal"
import { Stagger, StaggerItem } from "@registry/motion/stagger"
const items = ["Composable", "Accessible", "Themeable", "Framework-neutral"]
export function Demo() {
return (
<div className="flex flex-col gap-10">
<FadeIn className="bg-accent text-accent-foreground rounded-lg px-4 py-3 text-sm font-medium">
FadeIn — plays on mount
</FadeIn>
<Stagger as="ul" trigger="mount" className="grid gap-3 sm:grid-cols-2">
{items.map((item) => (
<StaggerItem
key={item}
as="li"
className="bg-card rounded-lg border px-4 py-3 text-sm font-medium"
>
{item}
</StaggerItem>
))}
</Stagger>
<Reveal
direction="left"
className="bg-accent text-accent-foreground rounded-lg px-4 py-3 text-sm font-medium"
>
Reveal — plays once, when scrolled into view
</Reveal>
</div>
)
}Source
What the CLI copies into your project, byte for byte.
/**
* Motion constants and variants shared by the `motion/` primitives.
*
* These numbers mirror the `--facade-duration-*` / `--facade-ease-*` CSS tokens.
* `motion` needs plain numbers and cubic-bezier arrays, which CSS custom
* properties cannot provide at render time, so the values are duplicated here —
* and `motion.test.ts` parses `tokens/globals.css` to prove they stay in sync.
*
* a11y: nothing here opts out of reduced motion. `FacadeMotionProvider` sets
* `reducedMotion="user"` once, which neutralises every variant below.
*
* Dependencies: motion (types only).
*/
import type { Transition, Variants } from "motion/react"
/** Milliseconds, matching `--facade-duration-*`. */
export const FACADE_DURATION_MS = {
fast: 150,
base: 320,
slow: 620,
} as const
/** Seconds — the unit `motion` expects. */
export const facadeDuration = {
fast: FACADE_DURATION_MS.fast / 1000,
base: FACADE_DURATION_MS.base / 1000,
slow: FACADE_DURATION_MS.slow / 1000,
} as const
/**
* Cubic-bezier control points, matching `--facade-ease-*`.
*
* Typed as mutable 4-tuples rather than `as const`: motion's `Easing[]` will not
* accept a readonly tuple, and `as const` widens under spread to a union array.
*/
export type CubicBezier = [number, number, number, number]
export const facadeEase: Record<"out" | "inOut" | "spring", CubicBezier> = {
out: [0.16, 1, 0.3, 1],
inOut: [0.65, 0, 0.35, 1],
spring: [0.34, 1.4, 0.64, 1],
}
/** rem, matching `--facade-motion-distance`. Converted to px for transforms. */
export const FACADE_MOTION_DISTANCE_REM = 1
export const FACADE_MOTION_DISTANCE_PX = FACADE_MOTION_DISTANCE_REM * 16
export type FadeDirection = "up" | "down" | "left" | "right" | "none"
/** Only `opacity` and `transform` are animated — never anything that reflows. */
export function offsetFor(
direction: FadeDirection,
distance = FACADE_MOTION_DISTANCE_PX,
) {
switch (direction) {
case "up":
return { y: distance, x: 0 }
case "down":
return { y: -distance, x: 0 }
case "left":
return { x: distance, y: 0 }
case "right":
return { x: -distance, y: 0 }
case "none":
return { x: 0, y: 0 }
}
}
export const facadeTransition = (
duration: number = facadeDuration.base,
delay = 0,
): Transition => ({
duration,
delay,
ease: facadeEase.out,
})
export function fadeVariants(
direction: FadeDirection = "up",
distance?: number,
duration?: number,
): Variants {
const offset = offsetFor(direction, distance)
return {
hidden: { opacity: 0, ...offset },
visible: { opacity: 1, x: 0, y: 0, transition: facadeTransition(duration) },
}
}
/** Container variants for `Stagger`. Children inherit `hidden`/`visible`. */
export function staggerVariants(stagger = 0.08, delayChildren = 0): Variants {
return {
hidden: {},
visible: {
transition: { staggerChildren: stagger, delayChildren },
},
}
}
/** Default viewport config for `Reveal`: fire once, slightly before fully in view. */
export const FACADE_VIEWPORT = {
once: true,
amount: 0.25,
margin: "0px 0px -10% 0px",
} as const"use client"
/**
* FacadeMotionProvider — one `MotionConfig` for the whole app.
*
* Mount it once, near the root. Everything in `motion/` assumes it is present
* but degrades to motion's own defaults if it is not.
*
* a11y: `reducedMotion="user"` makes every transform/opacity animation below a
* no-op for visitors whose OS asks for reduced motion. Combined with the
* `prefers-reduced-motion` block in `tokens/globals.css`, that covers both the
* JS and the CSS side.
*
* Dependencies: motion, react.
*/
import { MotionConfig } from "motion/react"
import type { ReactNode } from "react"
import { facadeDuration, facadeEase } from "@/lib/motion"
export interface FacadeMotionProviderProps {
children: ReactNode
/**
* `"user"` (default) honours the OS setting. `"always"` is useful for taking
* reduced-motion screenshots; `"never"` should only be used in tests.
*/
reducedMotion?: "user" | "always" | "never"
/** Disables `nonce`-less inline style injection in strict CSP setups. */
nonce?: string
}
export function FacadeMotionProvider({
children,
reducedMotion = "user",
nonce,
}: FacadeMotionProviderProps) {
return (
<MotionConfig
reducedMotion={reducedMotion}
nonce={nonce}
transition={{ duration: facadeDuration.base, ease: facadeEase.out }}
>
{children}
</MotionConfig>
)
}"use client"
/**
* FadeIn — the base entrance primitive: opacity plus a short translate.
*
* Plays on mount. For "play when scrolled into view" use `Reveal`; for lists use
* `Stagger` + `StaggerItem`, which drive their children through variants instead.
*
* a11y: animates only `opacity` and `transform`, so it can never shift layout or
* trigger CLS. Neutralised entirely under `FacadeMotionProvider`'s
* `reducedMotion="user"`.
*
* Dependencies: motion, react, @/lib/motion, @/lib/utils.
*/
import { motion } from "motion/react"
import type { ReactNode } from "react"
import { facadeDuration, fadeVariants, type FadeDirection } from "@/lib/motion"
import { cn } from "@/lib/utils"
/** Tags `FadeIn`, `Reveal` and `Stagger` can render as. */
export type MotionTag =
| "div"
| "span"
| "p"
| "section"
| "article"
| "header"
| "footer"
| "ul"
| "ol"
| "li"
| "dl"
| "figure"
export interface FadeInProps {
children: ReactNode
/** Direction the element travels *from*. `"none"` fades in place. */
direction?: FadeDirection
/** Seconds. Defaults to `--facade-duration-base`. */
duration?: number
/** Seconds. */
delay?: number
/** Travel distance in px. Defaults to `--facade-motion-distance` (16px). */
distance?: number
as?: MotionTag
className?: string
}
export function FadeIn({
children,
direction = "up",
duration = facadeDuration.base,
delay = 0,
distance,
as = "div",
className,
}: FadeInProps) {
const Component = motion[as]
const variants = fadeVariants(direction, distance, duration)
return (
<Component
className={cn(className)}
initial="hidden"
animate="visible"
variants={variants}
transition={{ delay }}
>
{children}
</Component>
)
}"use client"
/**
* Reveal — entrance animation triggered when the element scrolls into view.
*
* Fires once and stays visible. The `once` default matters: re-animating on every
* scroll pass is the single most common way marketing motion becomes annoying,
* and it also defeats browser find-in-page.
*
* a11y: opacity/transform only. The element is in the DOM and fully readable by
* assistive tech before the animation runs — nothing is gated behind the
* intersection observer.
*
* Dependencies: motion, react, @/lib/motion, @/lib/utils.
*/
import { motion } from "motion/react"
import type { ReactNode } from "react"
import {
FACADE_VIEWPORT,
facadeDuration,
fadeVariants,
type FadeDirection,
} from "@/lib/motion"
import { cn } from "@/lib/utils"
import type { MotionTag } from "@/components/motion/fade-in"
export interface RevealProps {
children: ReactNode
direction?: FadeDirection
duration?: number
delay?: number
distance?: number
as?: MotionTag
className?: string
/** Replay every time the element re-enters the viewport. Default `false`. */
repeat?: boolean
/** Fraction of the element that must be visible to trigger. Default `0.25`. */
amount?: number
}
export function Reveal({
children,
direction = "up",
duration = facadeDuration.base,
delay = 0,
distance,
as = "div",
className,
repeat = false,
amount = FACADE_VIEWPORT.amount,
}: RevealProps) {
const Component = motion[as]
return (
<Component
className={cn(className)}
initial="hidden"
whileInView="visible"
viewport={{ once: !repeat, amount, margin: FACADE_VIEWPORT.margin }}
variants={fadeVariants(direction, distance, duration)}
transition={{ delay }}
>
{children}
</Component>
)
}"use client"
/**
* Stagger — a container that reveals its `StaggerItem` children in sequence.
*
* Split into two components on purpose: the container owns the timing, each item
* owns its own offset, and neither needs to know how many siblings exist. Use
* `StaggerItem` for every direct child you want animated; unwrapped children
* simply render immediately.
*
* <Stagger as="ul">
* {items.map((item) => <StaggerItem key={item.id} as="li">…</StaggerItem>)}
* </Stagger>
*
* a11y: the container renders whatever tag you pass, so `ul`/`li` semantics
* survive the wrapper. Opacity/transform only.
*
* Dependencies: motion, react, @/lib/motion, @/lib/utils.
*/
import { motion } from "motion/react"
import type { ReactNode } from "react"
import {
FACADE_VIEWPORT,
facadeDuration,
fadeVariants,
staggerVariants,
type FadeDirection,
} from "@/lib/motion"
import { cn } from "@/lib/utils"
import type { MotionTag } from "@/components/motion/fade-in"
export interface StaggerProps {
children: ReactNode
/** Seconds between each child. Default `0.08`. */
stagger?: number
/** Seconds before the first child starts. Default `0`. */
delayChildren?: number
as?: MotionTag
className?: string
/** `"view"` (default) waits for scroll; `"mount"` plays immediately. */
trigger?: "view" | "mount"
repeat?: boolean
amount?: number
}
export function Stagger({
children,
stagger = 0.08,
delayChildren = 0,
as = "div",
className,
trigger = "view",
repeat = false,
amount = FACADE_VIEWPORT.amount,
}: StaggerProps) {
const Component = motion[as]
const activation =
trigger === "mount"
? ({ animate: "visible" } as const)
: ({
whileInView: "visible",
viewport: { once: !repeat, amount, margin: FACADE_VIEWPORT.margin },
} as const)
return (
<Component
className={cn(className)}
initial="hidden"
variants={staggerVariants(stagger, delayChildren)}
{...activation}
>
{children}
</Component>
)
}
export interface StaggerItemProps {
children: ReactNode
direction?: FadeDirection
duration?: number
distance?: number
as?: MotionTag
className?: string
}
export function StaggerItem({
children,
direction = "up",
duration = facadeDuration.base,
distance,
as = "div",
className,
}: StaggerItemProps) {
const Component = motion[as]
return (
<Component
className={cn(className)}
variants={fadeVariants(direction, distance, duration)}
>
{children}
</Component>
)
}"use client"
/**
* Collapse — animated disclosure for FAQ panels and the mobile nav drawer.
*
* The one deliberate exception to the "transform and opacity only" rule: a
* disclosure has to animate `height`, because the size change *is* the content of
* the interaction. It is user-initiated and never runs on load, so it cannot
* contribute to CLS. Everything else in `motion/` stays on the compositor.
*
* `Collapse` is presentation only — it renders no button and manages no state.
* Pair it with Base UI's Accordion or Collapsible, which own the ARIA wiring.
*
* a11y: when closed the panel is unmounted (or `hidden`, with `keepMounted`), so
* its contents stay out of the tab order and out of the accessibility tree.
*
* Dependencies: motion, react, @/lib/motion, @/lib/utils.
*/
import { AnimatePresence, motion } from "motion/react"
import type { ReactNode } from "react"
import { facadeDuration, facadeEase } from "@/lib/motion"
import { cn } from "@/lib/utils"
export interface CollapseProps {
open: boolean
children: ReactNode
/** Seconds. Defaults to `--facade-duration-fast`, which suits short panels. */
duration?: number
className?: string
/**
* Keep the panel mounted and hide it with `hidden` instead of removing it.
* Costs nothing visually but lets browser find-in-page reach the content.
*/
keepMounted?: boolean
/** Forwarded to the panel element — set this to the trigger's `aria-controls`. */
id?: string
}
const transition = (duration: number) => ({ duration, ease: facadeEase.inOut })
export function Collapse({
open,
children,
duration = facadeDuration.fast,
className,
keepMounted = false,
id,
}: CollapseProps) {
if (keepMounted) {
return (
<motion.div
id={id}
hidden={!open}
className={cn("overflow-hidden", className)}
initial={false}
animate={{ height: open ? "auto" : 0, opacity: open ? 1 : 0 }}
transition={transition(duration)}
>
{children}
</motion.div>
)
}
return (
<AnimatePresence initial={false}>
{open ? (
<motion.div
id={id}
className={cn("overflow-hidden", className)}
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={transition(duration)}
>
{children}
</motion.div>
) : null}
</AnimatePresence>
)
}"use client"
/**
* Motion adapters for the section slot props.
*
* Every `-motion` section variant needs the same four wrappers, differing only
* in the tag they render. Keeping them here means a motion variant is genuinely
* three lines of composition, and that a change to the choreography — timing,
* direction, travel — happens once rather than in a dozen near-identical files.
*
* The tag matters: `StaggerList` renders a `ul` and `StaggerListItem` an `li`,
* so wrapping a list in motion never costs it its list semantics.
*
* Dependencies: react, @/components/motion/stagger.
*/
import type { ReactNode } from "react"
import { Stagger, StaggerItem } from "@/components/motion/stagger"
export interface MotionSlotProps {
className?: string
children?: ReactNode
}
/** Drop-in for a section's `listAs`. Renders a `<ul>`. */
export function StaggerList({ className, children }: MotionSlotProps) {
return (
<Stagger as="ul" className={className}>
{children}
</Stagger>
)
}
/** Drop-in for a section's `itemAs`. Renders an `<li>`. */
export function StaggerListItem({ className, children }: MotionSlotProps) {
return (
<StaggerItem as="li" className={className}>
{children}
</StaggerItem>
)
}
/**
* Drop-in for a section's `stackAs`. Renders a `<div>` and plays on mount
* rather than on scroll, because a hero is above the fold by definition.
*/
export function StaggerStack({ className, children }: MotionSlotProps) {
return (
<Stagger trigger="mount" stagger={0.07} className={className}>
{children}
</Stagger>
)
}
/** Drop-in for a section's `blockAs`. Renders a `<div>`. */
export function StaggerBlock({ className, children }: MotionSlotProps) {
return <StaggerItem className={className}>{children}</StaggerItem>
}Props
FacadeMotionProviderProps
| Prop | Type | Default |
|---|---|---|
children*Required | ReactNode | — |
reducedMotion`"user"` (default) honours the OS setting. `"always"` is useful for taking
reduced-motion screenshots; `"never"` should only be used in tests. | "user" | "always" | "never" | "user" |
nonceDisables `nonce`-less inline style injection in strict CSP setups. | string | — |
FadeInProps
| Prop | Type | Default |
|---|---|---|
children*Required | ReactNode | — |
directionDirection the element travels *from*. `"none"` fades in place. | FadeDirection | "up" |
durationSeconds. Defaults to `--facade-duration-base`. | number | facadeDuration.base |
delaySeconds. | number | 0 |
distanceTravel distance in px. Defaults to `--facade-motion-distance` (16px). | number | — |
as | MotionTag | "div" |
className | string | — |
RevealProps
| Prop | Type | Default |
|---|---|---|
children*Required | ReactNode | — |
direction | FadeDirection | "up" |
duration | number | facadeDuration.base |
delay | number | 0 |
distance | number | — |
as | MotionTag | "div" |
className | string | — |
repeatReplay every time the element re-enters the viewport. Default `false`. | boolean | false |
amountFraction of the element that must be visible to trigger. Default `0.25`. | number | FACADE_VIEWPORT.amount |
StaggerProps
| Prop | Type | Default |
|---|---|---|
children*Required | ReactNode | — |
staggerSeconds between each child. Default `0.08`. | number | 0.08 |
delayChildrenSeconds before the first child starts. Default `0`. | number | 0 |
as | MotionTag | "div" |
className | string | — |
trigger`"view"` (default) waits for scroll; `"mount"` plays immediately. | "view" | "mount" | "view" |
repeat | boolean | false |
amount | number | FACADE_VIEWPORT.amount |
StaggerItemProps
| Prop | Type | Default |
|---|---|---|
children*Required | ReactNode | — |
direction | FadeDirection | "up" |
duration | number | facadeDuration.base |
distance | number | — |
as | MotionTag | "div" |
className | string | — |
CollapseProps
| Prop | Type | Default |
|---|---|---|
open*Required | boolean | — |
children*Required | ReactNode | — |
durationSeconds. Defaults to `--facade-duration-fast`, which suits short panels. | number | facadeDuration.fast |
className | string | — |
keepMountedKeep the panel mounted and hide it with `hidden` instead of removing it.
Costs nothing visually but lets browser find-in-page reach the content. | boolean | false |
idForwarded to the panel element — set this to the trigger's `aria-controls`. | string | — |
MotionSlotProps
| Prop | Type | Default |
|---|---|---|
className | string | — |
children | ReactNode | — |