wui
组件

动画列表 Animated List

逐条揭示列表项并将最新一条置顶,已有条目以弹簧动画下移,适用于通知流与活动记录。

第三方依赖 · motion

基础用法

按时间顺序传入带 key 的子元素,最后一项(最新)显示在最上方。父组件追加新条目时,新条目从顶部淡入,其余条目平滑下移;配合 max 限制可见数量,最旧的条目会从底部淡出:

Loading…

安装与引入

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

pnpm dlx @wui-design/cli@latest add @wui/animated-list
安装基础依赖与 Motion 动效库
pnpm add motion clsx tailwind-merge
复制组件源码到 components/ui/animated-list.tsx
components/ui/animated-list.tsx
"use client"

import * as React from "react"
import {
  AnimatePresence,
  motion,
  useReducedMotion,
  type Transition,
} from "motion/react"

import { cn } from "@/lib/utils"

export interface AnimatedListProps
  extends Omit<React.ComponentProps<"ul">, "children"> {
  /** Keyed items in chronological order; the newest (last) item shows on top. */
  children: React.ReactNode
  /**
   * Milliseconds between two reveals. Items not shown yet are revealed one at a
   * time; `0` shows every item immediately. @default 1000
   */
  delay?: number
  /** Maximum number of items kept on screen; older ones leave at the bottom. */
  max?: number
  /** Transition of entering items and of items shifting down. */
  transition?: Transition
  /** Class applied to every item wrapper. */
  itemClassName?: string
}

const defaultTransition: Transition = {
  type: "spring",
  stiffness: 380,
  damping: 32,
  mass: 0.8,
}

/**
 * A feed that reveals keyed items one by one with the newest on top, while
 * existing items slide down with a spring.
 */
function AnimatedList({
  children,
  delay = 1000,
  max,
  transition = defaultTransition,
  itemClassName,
  className,
  ...props
}: AnimatedListProps) {
  const reduceMotion = useReducedMotion()
  const items = React.Children.toArray(children).filter(React.isValidElement)
  const [revealed, setRevealed] = React.useState<ReadonlySet<React.Key>>(
    () => new Set()
  )
  const isRevealed = (item: React.ReactElement) =>
    delay === 0 || revealed.has(item.key!)
  const pendingKey = items.find((item) => !isRevealed(item))?.key ?? null

  React.useEffect(() => {
    if (pendingKey === null) return
    const timer = window.setTimeout(
      () => setRevealed((current) => new Set(current).add(pendingKey)),
      delay
    )
    return () => window.clearTimeout(timer)
  }, [delay, pendingKey])

  const visible = items.filter(isRevealed).reverse()
  const shown = max === undefined ? visible : visible.slice(0, max)
  const itemTransition = reduceMotion ? { duration: 0 } : transition

  return (
    <ul
      data-slot="animated-list"
      aria-live="polite"
      className={cn("relative flex flex-col gap-2", className)}
      {...props}
    >
      <AnimatePresence initial={false} mode="popLayout">
        {shown.map((item) => (
          <motion.li
            key={item.key}
            layout
            data-slot="animated-list-item"
            className={itemClassName}
            initial={{ opacity: 0, y: -12, scale: 0.96 }}
            animate={{ opacity: 1, y: 0, scale: 1 }}
            exit={{ opacity: 0, scale: 0.96 }}
            transition={itemTransition}
          >
            {item}
          </motion.li>
        ))}
      </AnimatePresence>
    </ul>
  )
}

export { AnimatedList }

属性 Props

AnimatedList 渲染为 <ul>,每个子元素被包裹在 <li> 中,并继承原生 <ul> 的其余 HTML 属性:

属性类型默认值说明
childrenReact.ReactNode—按时间先后排列、带有唯一 `key` 的元素,最后一项显示在最上方。
delaynumber1000逐条揭示的间隔(毫秒)。尚未显示的条目会按此节奏一条条出现;设为 0 时立即显示全部,由父组件控制新增节奏。
maxnumber—最多同时显示的条目数,超出的旧条目从底部淡出。
transitionTransition{ type: "spring", stiffness: 380, damping: 32, mass: 0.8 }条目入场与位置移动的过渡参数。
itemClassNamestring—应用于每个 `<li>` 包裹元素的类名。

使用场景与设计规范

AnimatedList 适合通知中心、实时活动流、构建日志等“新内容持续到达”的列表。

  • 保持稳定 key:条目必须使用稳定且唯一的 key(如消息 id),组件依赖它判断哪些条目是新增的。
  • 限制数量:实时流请配合 max,并在父组件中裁剪数据数组,避免无限增长。
  • 节奏适中:新增频率高于每秒一条时,逐条动画会让人眼花,建议合并为“有 N 条新消息”的提示。

场景示例

逐条揭示的构建记录

传入完整数组并设置 delay,条目会按间隔依次出现;通过改变 key 可以重新播放:

Loading…

无障碍与交互 Accessibility

  • 礼貌播报新增内容:列表默认带有 aria-live="polite",新增条目会在读屏空闲时播报。若列表更新非常频繁,可以传入 aria-live="off" 并提供其他提示方式。
  • 列表语义:使用 <ul> / <li> 结构,读屏软件可以读出条目数量。建议通过 aria-label 为列表命名。
  • 减少动态效果:开启 prefers-reduced-motion 时,条目的入场与位移动画即时完成。