Skip to content
Facade UI
Colour mode
GitHub (opens in a new tab)
componentmotion

Motion primitives

FacadeMotionProvider, FadeIn, Reveal, Stagger and Collapse. Opacity and transform only, with reduced motion honoured.

Preview

Open full width (opens in a new tab)
Preview width

Installation

Package manager
pnpm dlx shadcn@latest add https://facadeui.dev/r/motion-primitives.json

Pulls in motion@^13.4.0, utils. The CLI installs them for you.

Usage

The exact source of the preview above.

demos/motion-primitives.tsx
"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.

lib/motion.ts
/**
 * 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
components/motion/facade-motion-provider.tsx
"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>
  )
}
components/motion/fade-in.tsx
"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>
  )
}
components/motion/reveal.tsx
"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>
  )
}
components/motion/stagger.tsx
"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>
  )
}
components/motion/collapse.tsx
"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>
  )
}
components/motion/slots.tsx
"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

Props for FacadeMotionProviderProps
PropTypeDefault
children*RequiredReactNode
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

Props for FadeInProps
PropTypeDefault
children*RequiredReactNode
directionDirection the element travels *from*. `"none"` fades in place.FadeDirection"up"
durationSeconds. Defaults to `--facade-duration-base`.numberfacadeDuration.base
delaySeconds.number0
distanceTravel distance in px. Defaults to `--facade-motion-distance` (16px).number
asMotionTag"div"
classNamestring

RevealProps

Props for RevealProps
PropTypeDefault
children*RequiredReactNode
directionFadeDirection"up"
durationnumberfacadeDuration.base
delaynumber0
distancenumber
asMotionTag"div"
classNamestring
repeatReplay every time the element re-enters the viewport. Default `false`.booleanfalse
amountFraction of the element that must be visible to trigger. Default `0.25`.numberFACADE_VIEWPORT.amount

StaggerProps

Props for StaggerProps
PropTypeDefault
children*RequiredReactNode
staggerSeconds between each child. Default `0.08`.number0.08
delayChildrenSeconds before the first child starts. Default `0`.number0
asMotionTag"div"
classNamestring
trigger`"view"` (default) waits for scroll; `"mount"` plays immediately."view" | "mount""view"
repeatbooleanfalse
amountnumberFACADE_VIEWPORT.amount

StaggerItemProps

Props for StaggerItemProps
PropTypeDefault
children*RequiredReactNode
directionFadeDirection"up"
durationnumberfacadeDuration.base
distancenumber
asMotionTag"div"
classNamestring

CollapseProps

Props for CollapseProps
PropTypeDefault
open*Requiredboolean
children*RequiredReactNode
durationSeconds. Defaults to `--facade-duration-fast`, which suits short panels.numberfacadeDuration.fast
classNamestring
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.booleanfalse
idForwarded to the panel element — set this to the trigger's `aria-controls`.string

MotionSlotProps

Props for MotionSlotProps
PropTypeDefault
classNamestring
childrenReactNode