wui
组件

时间轴 Timeline

按照明确的时间先后顺序呈现事件流转、操作日志、发布进度与业务里程碑。

第三方依赖 · class-variance-authority第三方依赖 · motion

基础用法

垂直方向的基础事件时间轴。展示事件圆点、时间戳与说明文案:

Loading…

安装与引入

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

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

import * as React from "react"
import {
  inView,
  motion,
  useComposedRefs,
  useReducedMotion,
  type HTMLMotionProps,
} from "motion/react"
import { cva } from "class-variance-authority"

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

type TimelineOrientation = "vertical" | "horizontal"
type TimelineAlign = "start" | "alternate"
type TimelineDensity = "default" | "compact"
type TimelineSide = "left" | "right"

const ease = [0.22, 1, 0.36, 1] as const
const spring = { type: "spring", stiffness: 520, damping: 38, mass: 0.7 } as const
/** Seconds between items that enter the viewport in the same frame. */
const STAGGER = 0.07

const TimelineContext = React.createContext<{
  orientation: TimelineOrientation
  align: TimelineAlign
  density: TimelineDensity
  connector: "solid" | "dashed"
  animated: boolean
  nextDelay: () => number
}>({
  orientation: "vertical",
  align: "start",
  density: "default",
  connector: "solid",
  animated: false,
  nextDelay: () => 0,
})

const TimelineItemContext = React.createContext<{
  side: TimelineSide
  /** Whether the item has entered the viewport (always true when not animated). */
  revealed: boolean
  /** Stagger delay, in seconds, assigned when the item entered the viewport. */
  delay: number
  animated: boolean
}>({
  side: "right",
  revealed: true,
  delay: 0,
  animated: false,
})

const timelineDotVariants = cva(
  "relative z-10 flex shrink-0 items-center justify-center border-2 border-background ring-1 transition-[background-color,color,box-shadow] duration-300 [&_svg]:size-4",
  {
    variants: {
      variant: {
        default: "bg-muted-foreground text-background ring-border",
        primary: "bg-primary text-primary-foreground ring-primary/30",
        success: "bg-success text-success-foreground ring-success/30",
        warning: "bg-warning text-warning-foreground ring-warning/30",
        destructive:
          "bg-destructive text-destructive-foreground ring-destructive/30",
      },
      size: {
        dot: "size-2.5 rounded-full",
        icon: "size-8 rounded-full",
      },
    },
    defaultVariants: { variant: "default", size: "dot" },
  }
)

const timelinePulseVariants = {
  default: "bg-muted-foreground/20",
  primary: "bg-primary/20",
  success: "bg-success/20",
  warning: "bg-warning/20",
  destructive: "bg-destructive/20",
} as const

export interface TimelineProps extends React.ComponentProps<"ol"> {
  /** Timeline direction. @default "vertical" */
  orientation?: TimelineOrientation
  /** Content alignment. Alternate alignment only applies vertically. @default "start" */
  align?: TimelineAlign
  /** Spacing preset between events. @default "default" */
  density?: TimelineDensity
  /** Connector line treatment. @default "solid" */
  connector?: "solid" | "dashed"
  /** Reveal items with a staggered fade, marker pop and connector draw as they scroll into view. @default true */
  animated?: boolean
}

/** A composable chronological list supporting vertical, alternate, and horizontal layouts. */
function Timeline({
  className,
  orientation = "vertical",
  align = "start",
  density = "default",
  connector = "solid",
  animated = true,
  ...props
}: TimelineProps) {
  const resolvedAlign = orientation === "horizontal" ? "start" : align
  const reduceMotion = useReducedMotion()
  const batch = React.useRef({ count: 0, frame: 0 })

  React.useEffect(() => {
    const current = batch.current
    return () => cancelAnimationFrame(current.frame)
  }, [])

  // Items entering the viewport in the same frame share a batch and stagger;
  // items revealed later by scrolling start immediately.
  const nextDelay = React.useCallback(() => {
    const current = batch.current
    if (!current.frame) {
      current.frame = requestAnimationFrame(() => {
        current.count = 0
        current.frame = 0
      })
    }
    const delay = Math.min(current.count, 8) * STAGGER
    current.count += 1
    return delay
  }, [])

  return (
    <TimelineContext.Provider
      value={{
        orientation,
        align: resolvedAlign,
        density,
        connector,
        animated: animated && !reduceMotion,
        nextDelay,
      }}
    >
      <ol
        data-slot="timeline"
        data-orientation={orientation}
        data-align={resolvedAlign}
        data-density={density}
        className={cn(
          "relative",
          orientation === "vertical"
            ? "flex flex-col"
            : "grid min-w-max auto-cols-[minmax(10rem,1fr)] grid-flow-col",
          className
        )}
        {...props}
      />
    </TimelineContext.Provider>
  )
}

export interface TimelineItemProps extends React.ComponentProps<"li"> {
  /** Side used by alternate timelines. @default "right" */
  side?: TimelineSide
  /** Semantic progression state. @default "default" */
  state?: "default" | "complete" | "current" | "upcoming"
}

function TimelineItem({
  className,
  side = "right",
  state = "default",
  ref: externalRef,
  ...props
}: TimelineItemProps) {
  const { orientation, align, density, animated, nextDelay } =
    React.useContext(TimelineContext)
  const alternate = orientation === "vertical" && align === "alternate"
  const ref = React.useRef<HTMLLIElement>(null)
  const composedRef = useComposedRefs(ref, externalRef)
  const [reveal, setReveal] = React.useState({ shown: false, delay: 0 })

  React.useEffect(() => {
    if (!animated || !ref.current) return
    let done = false
    return inView(
      ref.current,
      () => {
        if (done) return
        done = true
        setReveal({ shown: true, delay: nextDelay() })
      },
      { margin: "0px 0px -8% 0px" }
    )
  }, [animated, nextDelay])

  const revealed = !animated || reveal.shown
  const hidden =
    orientation === "horizontal" ? { opacity: 0, x: -8 } : { opacity: 0, y: 8 }

  return (
    <TimelineItemContext.Provider
      value={{ side, revealed, delay: reveal.delay, animated }}
    >
      <motion.li
        ref={composedRef}
        data-slot="timeline-item"
        data-state={state}
        data-side={side}
        className={cn(
          "group relative",
          orientation === "horizontal"
            ? "grid min-w-40 grid-rows-[2rem_auto] gap-y-3 pr-6 last:pr-0"
            : alternate
              ? "grid grid-cols-[minmax(0,1fr)_2rem_minmax(0,1fr)] gap-x-4"
              : "grid grid-cols-[2rem_minmax(0,1fr)] gap-x-3",
          orientation === "vertical" &&
            (density === "compact" ? "pb-4 last:pb-0" : "pb-7 last:pb-0"),
          state === "upcoming" && "[&_[data-slot=timeline-content]]:opacity-55",
          className
        )}
        initial={animated ? hidden : false}
        animate={revealed ? { opacity: 1, x: 0, y: 0 } : hidden}
        transition={{ duration: 0.32, ease, delay: reveal.delay }}
        {...(props as HTMLMotionProps<"li">)}
      />
    </TimelineItemContext.Provider>
  )
}

export interface TimelineDotProps extends Omit<
  React.ComponentProps<"span">,
  "children"
> {
  /** Semantic marker color. @default "default" */
  variant?: "default" | "primary" | "success" | "warning" | "destructive"
  /** Marker shape. Icon markers accept an icon as children. @default "dot" */
  size?: "dot" | "icon"
  /** Draw attention to the current event with a subtle pulse. @default false */
  pulse?: boolean
  /** Optional icon rendered inside an icon-sized marker. */
  children?: React.ReactNode
}

function TimelineDot({
  className,
  variant = "default",
  size = "dot",
  pulse = false,
  children,
  ...props
}: TimelineDotProps) {
  const { orientation, align } = React.useContext(TimelineContext)
  const { side, revealed, delay, animated } =
    React.useContext(TimelineItemContext)
  const reduceMotion = useReducedMotion()
  const alternate = orientation === "vertical" && align === "alternate"

  return (
    <span
      data-slot="timeline-marker"
      className={cn(
        "relative flex self-stretch",
        orientation === "horizontal"
          ? "row-start-1 items-center"
          : "items-start justify-center pt-1.5",
        alternate && "col-start-2 row-start-1",
        !alternate && orientation === "vertical" && "col-start-1",
        side === "left" && alternate && "col-start-2"
      )}
    >
      <TimelineSeparator markerSize={size} />
      {pulse ? (
        <motion.span
          aria-hidden
          className={cn(
            "absolute z-0 rounded-full",
            timelinePulseVariants[variant],
            size === "icon" ? "size-8" : "size-2.5"
          )}
          style={{
            left:
              orientation === "horizontal"
                ? size === "icon"
                  ? 16
                  : 5
                : "50%",
            top: orientation === "horizontal" ? 16 : size === "icon" ? 22 : 11,
            x: "-50%",
            y: "-50%",
          }}
          animate={
            reduceMotion
              ? { opacity: 0.35 }
              : { opacity: [0.45, 0], scale: [1, 2.2] }
          }
          transition={
            reduceMotion
              ? { duration: 0 }
              : { duration: 1.8, ease: "easeOut", repeat: Infinity }
          }
        />
      ) : null}
      <motion.span
        data-slot="timeline-dot"
        data-variant={variant}
        className={cn(timelineDotVariants({ variant, size }), className)}
        initial={animated ? { scale: 0.4, opacity: 0 } : false}
        animate={
          revealed ? { scale: 1, opacity: 1 } : { scale: 0.4, opacity: 0 }
        }
        transition={{ ...spring, delay: delay + 0.05 }}
        {...(props as HTMLMotionProps<"span">)}
      >
        {children}
      </motion.span>
    </span>
  )
}

export interface TimelineSeparatorProps extends React.ComponentProps<"span"> {
  /** Marker size used to align the connector with the marker edge. @default "dot" */
  markerSize?: "dot" | "icon"
}

function TimelineSeparator({
  className,
  markerSize = "dot",
  style,
  ...props
}: TimelineSeparatorProps) {
  const { orientation, density, connector } = React.useContext(TimelineContext)
  const { revealed, delay, animated } = React.useContext(TimelineItemContext)
  const axis = orientation === "vertical" ? "scaleY" : "scaleX"

  return (
    <motion.span
      aria-hidden
      data-slot="timeline-separator"
      className={cn(
        "absolute group-last:hidden",
        orientation === "vertical"
          ? cn(
              "left-1/2 w-px -translate-x-1/2",
              markerSize === "icon" ? "top-[2.375rem]" : "top-4",
              density === "compact"
                ? "bottom-[-1.375rem]"
                : "bottom-[-2.125rem]"
            )
          : cn(
              // Markers sit at the column start, so the line runs from the
              // marker edge to the next item's marker (one `pr-6` gutter away).
              "-right-6 top-1/2 h-px -translate-y-1/2",
              markerSize === "icon" ? "left-8" : "left-2.5"
            ),
        connector === "dashed"
          ? orientation === "vertical"
            ? "border-border border-l border-dashed"
            : "border-border border-t border-dashed"
          : "bg-border",
        className
      )}
      style={{ originX: 0, originY: 0, ...style }}
      initial={animated ? { [axis]: 0 } : false}
      animate={{ [axis]: revealed ? 1 : 0 }}
      transition={{ duration: 0.5, ease, delay: delay + 0.12 }}
      {...(props as HTMLMotionProps<"span">)}
    />
  )
}

function TimelineContent({ className, ...props }: React.ComponentProps<"div">) {
  const { orientation, align } = React.useContext(TimelineContext)
  const { side } = React.useContext(TimelineItemContext)
  const alternate = orientation === "vertical" && align === "alternate"

  return (
    <div
      data-slot="timeline-content"
      className={cn(
        "min-w-0 transition-opacity duration-300",
        orientation === "horizontal" && "row-start-2",
        alternate && side === "left" && "col-start-1 row-start-1 text-right",
        alternate && side === "right" && "col-start-3 row-start-1 text-left",
        className
      )}
      {...props}
    />
  )
}

function TimelineHeader({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="timeline-header"
      className={cn(
        "flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1",
        className
      )}
      {...props}
    />
  )
}

function TimelineTitle({ className, ...props }: React.ComponentProps<"h3">) {
  return (
    <h3
      data-slot="timeline-title"
      className={cn("text-sm font-medium leading-5", className)}
      {...props}
    />
  )
}

function TimelineTime({ className, ...props }: React.ComponentProps<"time">) {
  return (
    <time
      data-slot="timeline-time"
      className={cn("text-muted-foreground text-xs tabular-nums", className)}
      {...props}
    />
  )
}

function TimelineDescription({
  className,
  ...props
}: React.ComponentProps<"p">) {
  return (
    <p
      data-slot="timeline-description"
      className={cn("text-muted-foreground mt-1 text-sm leading-6", className)}
      {...props}
    />
  )
}

function TimelineCard({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="timeline-card"
      className={cn(
        "bg-background shadow-xs mt-2 rounded-md border p-3 text-left",
        className
      )}
      {...props}
    />
  )
}

function TimelineMeta({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="timeline-meta"
      className={cn(
        "text-muted-foreground mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs",
        className
      )}
      {...props}
    />
  )
}

export {
  Timeline,
  TimelineCard,
  TimelineContent,
  TimelineDescription,
  TimelineDot,
  TimelineHeader,
  TimelineItem,
  TimelineMeta,
  TimelineSeparator,
  TimelineTime,
  TimelineTitle,
  timelineDotVariants,
}

属性 Props

Timeline (根容器)

继承原生 <ol> 元素的全部 HTML 属性:

属性类型默认值说明
orientation"vertical" | "horizontal""vertical"时间轴的排列方向。'vertical' 纵向适合历史日志与审核流;'horizontal' 横向适合横版向导步骤。
align"start" | "alternate""start"节点的对齐方式。'start' 靠单侧对齐;'alternate' 左右交错穿插对齐(仅纵向生效)。
density"default" | "compact""default"节点之间的垂直间距密度。'compact' 适合密集系统日志。
connector"solid" | "dashed""solid"相邻节点之间的连线样式。'solid' 实线,'dashed' 虚线。
animatedbooleantrue节点滚动进入视口时依次淡入、圆点弹出并由上至下绘制连线;同一帧进入视口的节点自动错峰。系统开启减弱动态效果时自动关闭。

TimelineItem

继承原生 <li> 元素的全部 HTML 属性:

属性类型默认值说明
state"default" | "complete" | "current" | "upcoming""default"当前事件项的业务流转状态。'upcoming' 会自动半透明淡化内容。
side"left" | "right""right"交错模式(alternate)下该节点偏向左侧还是右侧。

TimelineDot

时间轴上的关键事件节点圆点/图标:

属性类型默认值说明
variant"default" | "primary" | "success" | "warning" | "destructive""default"节点的语义色彩指示。
size"dot" | "icon""dot"节点形态。'dot' 为小圆点;'icon' 为可内嵌 Lucide 图标的圆形底座。
pulsebooleanfalse是否为当前正在进行中的节点开启呼吸水波纹扩散动效。

TimelineCard / TimelineHeader / TimelineTitle / TimelineTime / TimelineDescription / TimelineMeta

用于组合丰富内容展示的子组件:

属性类型默认值说明
childrenReact.ReactNode—子组件内渲染的内容。
classNamestring—应用于相应子容器的额外 CSS 类名。

事件 Events

继承原生 <ol> 与 <li> 的标准 HTML 事件:

属性类型默认值说明
onClick(event: React.MouseEvent) => void—点击时间轴节点时触发,可用于展开事件卡片明细或跳转关联详情页。

使用场景与设计规范

Timeline 用于强化事件之间的时间序列感与因果演变:

  • Timeline vs Steps:Steps 侧重于用户当前需要完成的操作向导(如多步表单填报);Timeline 侧重于记录已经发生或即将发生的系统事件(如物流轨迹、发布历史、CI/CD 构建日志)。
  • 色彩语义搭配:
    • success:构建成功、已送达、审核通过。
    • primary:当前执行中(建议搭配 pulse 水波动效)。
    • destructive:异常报错、构建失败、阻断拦截。
    • warning:警告、排队等待中。
  • 入场动效:默认开启的 animated 让节点在滚动进入视口时淡入、圆点弹出、连线由上至下(横向为由左至右)绘制;超长的审计日志可传 animated={false} 直接呈现。
  • 动效降级保障:TimelineDot 的 pulse 呼吸扩散动效与节点入场动效在系统启用“减少动态效果(prefers-reduced-motion)”时会自动关闭,pulse 降级为静态微透光环。

场景示例

实时推进的发布流程

状态切换时圆点颜色平滑过渡,当前阶段带呼吸脉冲;点击「重放」可重新观察入场与连线绘制动效:

Loading…

流程阶段与呼吸脉冲

通过 state 标记进度,并为“进行中”状态启用 pulse 水波纹扩散:

Loading…

图标节点与独立卡片

使用 TimelineDot size="icon" 承载图标,使用 TimelineCard 展示 Git 提交或详情卡片:

Loading…

左右交错里程碑 (Alternate)

适合公司大事记、产品重大版本迭代路线图:

Loading…

横向向导流程 (Horizontal)

横版排列节点,适用于页面顶部横向阶段指示:

Loading…

紧凑型系统审计日志 (Compact)

高密度排列,适合海量运维操作流水记录:

Loading…

无障碍与交互 Accessibility

  • 有序列表语义:采用标准 <ol> 与 <li> 结构,屏幕阅读器会自动播报“第 X 项,共 Y 项”,清晰传递顺序信息。
  • 时间标签解析:TimelineTime 渲染为原生 <time> 元素,便于读屏软件与浏览器无障碍辅助引擎准确解析时间。
  • 连线与装饰隔离:TimelineSeparator 自动携带 aria-hidden="true",不产生无意义的读屏杂音。