wui
组件

动效 Motion

基于 Motion 构建的声明式动画包装组件与预设库,提供开箱即用的入场动效、视口触发与无障碍降级。

第三方依赖 · motion第三方依赖 · radix-ui

基础用法

最简单的动效用法。将需要动画的元素包裹在 Motion 内即可在挂载时执行预设动画:

Loading…

安装与引入

通过 CLI 自动添加组件,或手动复制源码至项目中:

pnpm dlx @wui-design/cli@latest add @wui/motion
安装动效依赖与工具库
pnpm add motion radix-ui
复制组件源码到 components/ui/motion.tsx
components/ui/motion.tsx
"use client"

import * as React from "react"
import { Slot } from "radix-ui"
import {
  motion,
  useReducedMotion,
  type HTMLMotionProps,
  type Transition,
  type Variants,
} from "motion/react"

export type MotionPreset =
  | "fade"
  | "scale"
  | "slide-up"
  | "slide-down"
  | "slide-left"
  | "slide-right"
  | "pop"
  | "blur"
  | "blur-up"
  | "zoom-in"
  | "zoom-out"
  | "flip"

/** Enter animations, expressed as `hidden` → `visible` variants. */
export const motionPresets: Record<MotionPreset, Variants> = {
  fade: { hidden: { opacity: 0 }, visible: { opacity: 1 } },
  scale: {
    hidden: { opacity: 0, scale: 0.95 },
    visible: { opacity: 1, scale: 1 },
  },
  "slide-up": {
    hidden: { opacity: 0, y: 12 },
    visible: { opacity: 1, y: 0 },
  },
  "slide-down": {
    hidden: { opacity: 0, y: -12 },
    visible: { opacity: 1, y: 0 },
  },
  "slide-left": {
    hidden: { opacity: 0, x: 12 },
    visible: { opacity: 1, x: 0 },
  },
  "slide-right": {
    hidden: { opacity: 0, x: -12 },
    visible: { opacity: 1, x: 0 },
  },
  pop: {
    hidden: { opacity: 0, scale: 0.8 },
    visible: { opacity: 1, scale: 1 },
  },
  blur: {
    hidden: { opacity: 0, filter: "blur(6px)" },
    visible: { opacity: 1, filter: "blur(0px)" },
  },
  "blur-up": {
    hidden: { opacity: 0, y: 14, filter: "blur(8px)" },
    visible: { opacity: 1, y: 0, filter: "blur(0px)" },
  },
  "zoom-in": {
    hidden: { opacity: 0, scale: 0.6 },
    visible: { opacity: 1, scale: 1 },
  },
  "zoom-out": {
    hidden: { opacity: 0, scale: 1.12, filter: "blur(4px)" },
    visible: { opacity: 1, scale: 1, filter: "blur(0px)" },
  },
  flip: {
    hidden: { opacity: 0, rotateX: -70, y: 8, transformPerspective: 800 },
    visible: { opacity: 1, rotateX: 0, y: 0, transformPerspective: 800 },
  },
}

/** Reusable transition presets. */
export const motionTransitions = {
  smooth: { duration: 0.3, ease: "easeOut" },
  spring: { type: "spring", stiffness: 400, damping: 30, mass: 0.7 },
  snappy: { type: "spring", stiffness: 700, damping: 35 },
  gentle: { type: "spring", bounce: 0, visualDuration: 0.5 },
  bouncy: { type: "spring", bounce: 0.35, visualDuration: 0.45 },
} satisfies Record<string, Transition>

export type MotionTransition = keyof typeof motionTransitions

function resolveTransition(transition: MotionTransition | Transition) {
  return typeof transition === "string"
    ? motionTransitions[transition]
    : transition
}

type MotionGroupContextValue = {
  preset?: MotionPreset
  transition?: MotionTransition | Transition
}

const MotionGroupContext = React.createContext<MotionGroupContextValue | null>(
  null
)

const MotionSlot = motion.create(Slot.Root)

export interface MotionProps
  extends Omit<HTMLMotionProps<"div">, "variants" | "transition"> {
  /** Named enter animation. Inherits from `MotionGroup` when omitted. @default "fade" */
  preset?: MotionPreset
  /** A named transition preset or a custom motion Transition. @default "smooth" */
  transition?: MotionTransition | Transition
  /** Delay before animating, in seconds. Ignored inside `MotionGroup`. */
  delay?: number
  /** Animate when scrolled into view instead of on mount. @default false */
  inView?: boolean
  /** Only animate the first time it enters the viewport. @default true */
  once?: boolean
  /** Merge motion onto the single child element instead of rendering a div. */
  asChild?: boolean
}

/**
 * A small opt-in wrapper that animates its children with a named preset.
 * Respects `prefers-reduced-motion` (jumps straight to the final state). Use
 * `asChild` to animate an existing element (e.g. a Button) without an extra
 * wrapper node. Inside `MotionGroup`, it follows the group's stagger timing.
 */
function Motion({
  preset,
  transition,
  delay,
  inView = false,
  once = true,
  asChild = false,
  ...props
}: MotionProps) {
  const group = React.useContext(MotionGroupContext)
  const reduceMotion = useReducedMotion()
  const Comp = (asChild ? MotionSlot : motion.div) as React.ElementType

  // Reduced motion keeps the exact same markup (so server and client HTML
  // match) and simply jumps to the final state.
  const resolvedTransition = reduceMotion
    ? { duration: 0 }
    : resolveTransition(transition ?? group?.transition ?? "smooth")
  const variants = motionPresets[preset ?? group?.preset ?? "fade"]

  // Inside a group the parent orchestrates `hidden` → `visible` so that
  // `staggerChildren` can sequence every item.
  const activation = group
    ? {}
    : inView
      ? { initial: "hidden", whileInView: "visible", viewport: { once } }
      : { initial: "hidden", animate: "visible" }

  return (
    <Comp
      data-slot="motion"
      variants={variants}
      transition={
        group || reduceMotion
          ? resolvedTransition
          : { ...resolvedTransition, delay }
      }
      {...activation}
      {...props}
    />
  )
}

export interface MotionGroupProps
  extends Omit<HTMLMotionProps<"div">, "variants" | "transition"> {
  /** Seconds between each child `Motion` starting. @default 0.06 */
  stagger?: number
  /** Delay before the first child animates, in seconds. @default 0 */
  delay?: number
  /** Default preset for child `Motion` elements without their own preset. */
  preset?: MotionPreset
  /** Default transition for child `Motion` elements. */
  transition?: MotionTransition | Transition
  /** Start the sequence when the group scrolls into view. @default false */
  inView?: boolean
  /** Only play the sequence the first time it enters the viewport. @default true */
  once?: boolean
  /** Merge the group onto its single child element instead of rendering a div. */
  asChild?: boolean
}

/**
 * Orchestrates nested `Motion` children so they enter one after another.
 * Children inherit the group's `preset` and `transition` unless they set
 * their own.
 */
function MotionGroup({
  stagger = 0.06,
  delay = 0,
  preset,
  transition,
  inView = false,
  once = true,
  asChild = false,
  ...props
}: MotionGroupProps) {
  const reduceMotion = useReducedMotion()
  const Comp = (asChild ? MotionSlot : motion.div) as React.ElementType
  const context = React.useMemo(
    () => ({ preset, transition }),
    [preset, transition]
  )

  const activation = inView
    ? { initial: "hidden", whileInView: "visible", viewport: { once } }
    : { initial: "hidden", animate: "visible" }

  return (
    <MotionGroupContext.Provider value={context}>
      <Comp
        data-slot="motion-group"
        variants={{
          hidden: {},
          visible: {
            transition: reduceMotion
              ? {}
              : { staggerChildren: stagger, delayChildren: delay },
          },
        }}
        {...activation}
        {...props}
      />
    </MotionGroupContext.Provider>
  )
}

export { Motion, MotionGroup }

属性 Props

Motion 接受以下预设与配置属性,并支持 motion.div 的全部动画属性(如 whileHover、whileTap 等):

属性类型默认值说明
preset"fade" | "scale" | "slide-up" | "slide-down" | "slide-left" | "slide-right" | "pop" | "blur" | "blur-up" | "zoom-in" | "zoom-out" | "flip""fade"命名的入场动画预设,包含透明度、位移、缩放、翻转与高斯模糊等组合效果。位于 MotionGroup 内且未设置时继承分组预设。
transition"smooth" | "spring" | "snappy" | "gentle" | "bouncy" | Transition"smooth"过渡动画曲线预设:smooth 为缓出补间,spring / snappy 为紧致弹簧,gentle 为无回弹的柔和弹簧,bouncy 带轻微回弹。也可传入 Motion 原生的 Transition 对象。
delaynumber0动画开始前的延迟等待时间(秒)。位于 MotionGroup 内时由分组统一编排,此属性被忽略。
inViewbooleanfalse是否开启视口滚动触发。开启后仅当元素进入浏览器可视区域时才开始执行动画。
oncebooleantrue配合 inView 使用。是否仅在首次进入视口时触发一次动画(避免上下反复滚动时重复闪烁)。
asChildbooleanfalse是否将动画能力直接附加到唯一子元素上(基于 Radix Slot 与 motion.create),避免多余的 <div> 嵌套。
classNamestring—应用于外层容器的额外 CSS 类名。

MotionGroup

MotionGroup 负责编排内部的 Motion 子元素依次入场,子元素无需再手动计算 delay:

属性类型默认值说明
staggernumber0.06相邻子元素开始动画的时间间隔(秒)。
delaynumber0第一个子元素开始前的等待时间(秒)。
presetMotionPreset—子元素未设置 preset 时使用的默认预设。
transitionMotionTransition | Transition—子元素未设置 transition 时使用的默认过渡。
inViewbooleanfalse整组滚动进入视口时才开始依次播放。
oncebooleantrue配合 inView 使用,是否只播放一次。
asChildbooleanfalse将分组能力合并到唯一子元素上(如 ul),不额外渲染 div。

事件 Events

Motion 透传底层 Motion 动画生命周期事件:

属性类型默认值说明
onAnimationStart() => void—动画开始播放时触发。
onAnimationComplete(definition: string | TargetAndTransition) => void—动画播放结束到达目标状态后触发。
onViewportEnter() => void—配合 inView 使用,当元素进入视口可见范围时触发。
onViewportLeave() => void—配合 inView 使用,当元素滚出视口可见范围时触发。

使用场景与设计规范

Motion 旨在为应用提供统一且可控的微动效系统,避免各页面各组件中随意编写杂乱生硬的 CSS Keyframes。

  • 按需增强与体验克制:
    • 入场动效应该“润物细无声”,推荐位移距离控制在 12px 以内、时长控制在 200ms~400ms 之间。
    • 避免在用户高频操作(如快速切换 Tab、表格分页切换)中加入冗长动效,这会增加用户的等待感知。
  • 交错动画(Staggering):
    • 渲染列表、数据卡片网格或时间线时,用 MotionGroup 包裹各项 Motion,由分组统一设置间隔与预设,营造有秩序感的连贯登场。
  • DOM 结构精简:
    • 当需要给现有组件(如 Button、Card)增加入场动效时,优先使用 asChild 属性,保持语义结构扁平干净。

场景示例

动画预设库(Presets)

内置 12 种精调的入场动效方案,覆盖绝大部分界面动画需求,点击任意格子重播:

Loading…
  • fade:纯透明度淡入,通用稳妥。
  • scale:轻微缩放放大淡入,适合弹窗、下拉菜单。
  • slide-up / slide-down:垂直方向轻微位移滑入,适合卡片与列表。
  • slide-left / slide-right:水平方向滑入,适合侧边抽屉或向导流程。
  • pop:弹性质感缩放,适合徽标或重点提示。
  • blur:高斯模糊消散淡入,呈现现代质感。
  • blur-up:模糊对焦的同时轻微上浮,适合通知、结果反馈与首屏标题。
  • zoom-in / zoom-out:从小放大或从大收拢,适合空状态插图、媒体预览。
  • flip:沿 X 轴翻转立起,适合卡片翻面与数据刷新。

列表交错排队入场(Stagger)

用 MotionGroup 统一编排列表项的交错进场,子项继承分组的 preset 与 transition:

Loading…

视口滚动监听(inView)

当页面较长时,配置 inView 让下方内容在滚动至用户屏幕内时才触发动画:

Loading…

零额外 DOM 包装(asChild)

使用 asChild 可以将动效直接注入给子组件,无需产生多余的 <div>:

Loading…

无障碍与交互 Accessibility

  • 自动无障碍降级:Motion 与 MotionGroup 内部集成了 useReducedMotion 钩子。当用户系统开启了“减少动态效果(prefers-reduced-motion)”时,组件保持相同的 DOM 结构(避免服务端与客户端渲染不一致),并以零时长直接跳到最终可见状态,不产生位移或缩放过程。
  • 子元素焦点与事件保留:使用 asChild 时,子组件的原生事件、键盘 Tab 导航与 Focus Ring 均能完美透传,不破坏原有的可访问性树结构。