组件
动效 Motion
基于 Motion 构建的声明式动画包装组件与预设库,提供开箱即用的入场动效、视口触发与无障碍降级。
基础用法
最简单的动效用法。将需要动画的元素包裹在 Motion 内即可在挂载时执行预设动画:
Loading…
安装与引入
通过 CLI 自动添加组件,或手动复制源码至项目中:
pnpm dlx @wui-design/cli@latest add @wui/motion安装动效依赖与工具库
pnpm add motion radix-ui复制组件源码到
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 对象。 |
| delay | number | 0 | 动画开始前的延迟等待时间(秒)。位于 MotionGroup 内时由分组统一编排,此属性被忽略。 |
| inView | boolean | false | 是否开启视口滚动触发。开启后仅当元素进入浏览器可视区域时才开始执行动画。 |
| once | boolean | true | 配合 inView 使用。是否仅在首次进入视口时触发一次动画(避免上下反复滚动时重复闪烁)。 |
| asChild | boolean | false | 是否将动画能力直接附加到唯一子元素上(基于 Radix Slot 与 motion.create),避免多余的 <div> 嵌套。 |
| className | string | — | 应用于外层容器的额外 CSS 类名。 |
MotionGroup
MotionGroup 负责编排内部的 Motion 子元素依次入场,子元素无需再手动计算 delay:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| stagger | number | 0.06 | 相邻子元素开始动画的时间间隔(秒)。 |
| delay | number | 0 | 第一个子元素开始前的等待时间(秒)。 |
| preset | MotionPreset | — | 子元素未设置 preset 时使用的默认预设。 |
| transition | MotionTransition | Transition | — | 子元素未设置 transition 时使用的默认过渡。 |
| inView | boolean | false | 整组滚动进入视口时才开始依次播放。 |
| once | boolean | true | 配合 inView 使用,是否只播放一次。 |
| asChild | boolean | false | 将分组能力合并到唯一子元素上(如 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 均能完美透传,不破坏原有的可访问性树结构。