组件
动画组 Animated Group
为一组子元素按统一预设错峰播放入场动画,支持挂载时或滚动进入视口时触发。
基础用法
把需要依次入场的元素放进 AnimatedGroup,每个直接子元素都会被包裹并按 stagger 间隔依次播放同一个预设动画。切换下方预设可对比不同效果:
Loading…
安装与引入
通过 CLI 自动添加组件,或手动复制源码至项目中:
pnpm dlx @wui-design/cli@latest add @wui/animated-group安装基础依赖与 Motion 动效库
pnpm add motion clsx tailwind-merge复制组件源码到
components/ui/animated-group.tsx"use client"
import * as React from "react"
import {
motion,
useReducedMotion,
type Transition,
type UseInViewOptions,
type Variants,
} from "motion/react"
import { cn } from "@/lib/utils"
export type AnimatedGroupPreset =
| "fade"
| "slide"
| "blur-slide"
| "zoom"
| "flip"
| "bounce"
| "scale"
const ease = [0.22, 1, 0.36, 1] as const
const tween: Transition = { duration: 0.5, ease }
/** Item variants of every preset, expressed as `hidden` → `visible`. */
export const animatedGroupPresets: Record<AnimatedGroupPreset, Variants> = {
fade: {
hidden: { opacity: 0 },
visible: { opacity: 1, transition: tween },
},
slide: {
hidden: { opacity: 0, y: 16 },
visible: { opacity: 1, y: 0, transition: tween },
},
"blur-slide": {
hidden: { opacity: 0, y: 16, filter: "blur(8px)" },
visible: { opacity: 1, y: 0, filter: "blur(0px)", transition: tween },
},
zoom: {
hidden: { opacity: 0, scale: 0.6 },
visible: {
opacity: 1,
scale: 1,
transition: { type: "spring", stiffness: 300, damping: 24 },
},
},
flip: {
hidden: { opacity: 0, rotateX: -80, transformPerspective: 800 },
visible: {
opacity: 1,
rotateX: 0,
transformPerspective: 800,
transition: { type: "spring", stiffness: 260, damping: 24 },
},
},
bounce: {
hidden: { opacity: 0, y: -32 },
visible: {
opacity: 1,
y: 0,
transition: { type: "spring", stiffness: 420, damping: 14, mass: 0.8 },
},
},
scale: {
hidden: { opacity: 0, scale: 0.94 },
visible: { opacity: 1, scale: 1, transition: tween },
},
}
type GroupElement = "div" | "ul" | "ol" | "section" | "span"
type ItemElement = "div" | "li" | "span" | "article"
export interface AnimatedGroupProps
extends Omit<React.ComponentProps<"div">, "children"> {
/** Items revealed one after another. Each direct child gets its own wrapper. */
children: React.ReactNode
/** Built-in entrance of every item. @default "slide" */
preset?: AnimatedGroupPreset
/** Custom item variants with `hidden` and `visible` states; overrides `preset`. */
variants?: Variants
/** Seconds between two items. @default 0.08 */
stagger?: number
/** Seconds before the first item starts. @default 0 */
delay?: number
/** Start when the group scrolls into view instead of on mount. @default false */
inView?: boolean
/** Play only the first time the group enters the viewport. @default true */
once?: boolean
/** Intersection options used when `inView` is on. */
viewOptions?: Omit<UseInViewOptions, "once">
/** Element rendered for the group. @default "div" */
as?: GroupElement
/** Element wrapping each item, e.g. `li` inside a `ul`. @default "div" */
itemAs?: ItemElement
/** Class applied to every item wrapper. */
itemClassName?: string
}
/**
* Staggers the entrance of its children with a shared preset, on mount or when
* the group scrolls into view.
*/
function AnimatedGroup({
children,
preset = "slide",
variants,
stagger = 0.08,
delay = 0,
inView = false,
once = true,
viewOptions,
as = "div",
itemAs = "div",
itemClassName,
className,
...props
}: AnimatedGroupProps) {
const reduceMotion = useReducedMotion()
const Group = motion[as] as React.ElementType
const Item = motion[itemAs] as React.ElementType
const itemVariants = variants ?? animatedGroupPresets[preset]
const containerVariants: Variants = {
hidden: {},
visible: {
transition: { staggerChildren: stagger, delayChildren: delay },
},
}
const activation = reduceMotion
? { initial: false, animate: "visible" }
: inView
? {
initial: "hidden",
whileInView: "visible",
viewport: { ...viewOptions, once },
}
: { initial: "hidden", animate: "visible" }
return (
<Group
data-slot="animated-group"
className={className}
variants={containerVariants}
{...activation}
{...props}
>
{React.Children.map(children, (child) => (
<Item
data-slot="animated-group-item"
className={cn(itemClassName)}
variants={itemVariants}
>
{child}
</Item>
))}
</Group>
)
}
export { AnimatedGroup }
属性 Props
AnimatedGroup 支持以下配置属性,并继承原生元素的其余 HTML 属性:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| children | React.ReactNode | — | 依次入场的元素,每个直接子元素会被包裹在一个 `itemAs` 元素中。 |
| preset | "fade" | "slide" | "blur-slide" | "zoom" | "flip" | "bounce" | "scale" | "slide" | 内置入场预设。zoom、flip、bounce 使用弹簧曲线,其余使用统一的缓出曲线。 |
| variants | Variants | — | 自定义子项变体,需包含 `hidden` 与 `visible` 两个状态,传入后覆盖 `preset`。 |
| stagger | number | 0.08 | 相邻两个子项之间的间隔(秒)。 |
| delay | number | 0 | 第一个子项开始前的延迟(秒)。 |
| inView | boolean | false | 是否在滚动进入视口时才开始播放。 |
| once | boolean | true | `inView` 模式下是否只播放一次。 |
| viewOptions | UseInViewOptions | — | 视口检测参数,例如 `{ amount: 0.4 }`。 |
| as | "div" | "ul" | "ol" | "section" | "span" | "div" | 组容器渲染的元素。 |
| itemAs | "div" | "li" | "span" | "article" | "div" | 包裹每个子项的元素,列表中请配合 `as="ul"` 使用 `li`。 |
| itemClassName | string | — | 应用于每个子项包裹元素的类名,网格布局中可用于设置背景或对齐。 |
组件同时导出 animatedGroupPresets,可以在其基础上扩展自定义变体。
使用场景与设计规范
AnimatedGroup 适合功能列表、卡片网格、更新日志等“一组同类元素”的入场。单个元素的入场请使用 动效 Motion 或 视口动画 In View。
- 一个页面一种节奏:同一页面内尽量统一预设与
stagger,避免每个区块使用不同动画。 - 控制总时长:子项较多时减小
stagger,让整组动画在 0.8 秒内完成;超过 12 项时建议只对首屏可见的部分使用动画。 - 弹性预设克制使用:
bounce与flip表现力强,适合营销页的少量元素,不建议用于后台数据列表。
场景示例
滚动触发的更新日志
inView 让列表在滚动进入视口时才开始入场;as="ol" 与 itemAs="li" 保持列表语义:
Loading…
无障碍与交互 Accessibility
- 语义不受影响:通过
as与itemAs选择合适的元素,包裹层不会破坏列表结构。 - 减少动态效果:开启
prefers-reduced-motion时,所有子项直接以最终状态渲染,不播放入场动画。 - 内容始终存在:入场动画只改变透明度与变换,子项在首屏 HTML 中完整输出,不影响搜索引擎与读屏软件读取。