wui
组件

动画组 Animated Group

为一组子元素按统一预设错峰播放入场动画,支持挂载时或滚动进入视口时触发。

第三方依赖 · motion

基础用法

把需要依次入场的元素放进 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
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 属性:

属性类型默认值说明
childrenReact.ReactNode—依次入场的元素,每个直接子元素会被包裹在一个 `itemAs` 元素中。
preset"fade" | "slide" | "blur-slide" | "zoom" | "flip" | "bounce" | "scale""slide"内置入场预设。zoom、flip、bounce 使用弹簧曲线,其余使用统一的缓出曲线。
variantsVariants—自定义子项变体,需包含 `hidden` 与 `visible` 两个状态,传入后覆盖 `preset`。
staggernumber0.08相邻两个子项之间的间隔(秒)。
delaynumber0第一个子项开始前的延迟(秒)。
inViewbooleanfalse是否在滚动进入视口时才开始播放。
oncebooleantrue`inView` 模式下是否只播放一次。
viewOptionsUseInViewOptions—视口检测参数,例如 `{ amount: 0.4 }`。
as"div" | "ul" | "ol" | "section" | "span""div"组容器渲染的元素。
itemAs"div" | "li" | "span" | "article""div"包裹每个子项的元素,列表中请配合 `as="ul"` 使用 `li`。
itemClassNamestring—应用于每个子项包裹元素的类名,网格布局中可用于设置背景或对齐。

组件同时导出 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 中完整输出,不影响搜索引擎与读屏软件读取。