组件
时间轴 Timeline
按照明确的时间先后顺序呈现事件流转、操作日志、发布进度与业务里程碑。
基础用法
垂直方向的基础事件时间轴。展示事件圆点、时间戳与说明文案:
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"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' 虚线。 |
| animated | boolean | true | 节点滚动进入视口时依次淡入、圆点弹出并由上至下绘制连线;同一帧进入视口的节点自动错峰。系统开启减弱动态效果时自动关闭。 |
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 图标的圆形底座。 |
| pulse | boolean | false | 是否为当前正在进行中的节点开启呼吸水波纹扩散动效。 |
TimelineCard / TimelineHeader / TimelineTitle / TimelineTime / TimelineDescription / TimelineMeta
用于组合丰富内容展示的子组件:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| children | React.ReactNode | — | 子组件内渲染的内容。 |
| className | string | — | 应用于相应子容器的额外 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",不产生无意义的读屏杂音。