时间轴 Timeline
以清晰的时间顺序展示事件、状态与说明。
基础示例
pnpm dlx wui@latest add @wui/timeline"use client"
import * as React from "react"
import { motion, useReducedMotion } 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 TimelineContext = React.createContext<{
orientation: TimelineOrientation
align: TimelineAlign
density: TimelineDensity
connector: "solid" | "dashed"
}>({
orientation: "vertical",
align: "start",
density: "default",
connector: "solid",
})
const TimelineItemContext = React.createContext<{ side: TimelineSide }>({
side: "right",
})
const timelineDotVariants = cva(
"relative z-10 flex shrink-0 items-center justify-center border-2 border-background ring-1 [&_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"
}
/** A composable chronological list supporting vertical, alternate, and horizontal layouts. */
function Timeline({
className,
orientation = "vertical",
align = "start",
density = "default",
connector = "solid",
...props
}: TimelineProps) {
const resolvedAlign = orientation === "horizontal" ? "start" : align
return (
<TimelineContext.Provider
value={{ orientation, align: resolvedAlign, density, connector }}
>
<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",
...props
}: TimelineItemProps) {
const { orientation, align, density } = React.useContext(TimelineContext)
const alternate = orientation === "vertical" && align === "alternate"
return (
<TimelineItemContext.Provider value={{ side }}>
<li
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
)}
{...props}
/>
</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 } = 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}
<span
data-slot="timeline-dot"
className={cn(timelineDotVariants({ variant, size }), className)}
{...props}
>
{children}
</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",
...props
}: TimelineSeparatorProps) {
const { orientation, density, connector } = React.useContext(TimelineContext)
return (
<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
)}
{...props}
/>
)
}
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",
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,
}
组件作用
Timeline 适合审计日志、发布记录、订单进度和活动历史。它强调事件顺序与因果阅读,不适合需要跨列比较的数据,也不要用大量装饰图标代替明确的标题与时间。
组件属性
| Prop | Type | Default | Description |
|---|---|---|---|
| orientation | "vertical" | "horizontal" | vertical | Timeline direction. |
| align | "start" | "alternate" | start | Content alignment. Alternate alignment only applies vertically. |
| density | "default" | "compact" | default | Spacing preset between events. |
| connector | "solid" | "dashed" | solid | Connector line treatment. |
事件
时间轴由语义化的 ol 与 li 构成,所有原生事件均会透传。组件不内置展开、分页或加载逻辑,可在 TimelineContent 内自由组合交互元素。
扩展使用
状态进度
通过 state="complete | current | upcoming" 表达流程阶段,并为当前节点开启 pulse。density="compact" 和虚线连接器适合订单、审批等步骤紧密的流程;颜色与文案应同时表达状态,不能只依赖色相。
图标节点与富内容
TimelineDot size="icon" 可以承载 Lucide 图标。TimelineCard 用于提交记录、附件、引用内容等需要独立边界的信息,TimelineMeta 用于作者、文件数和辅助状态。
左右交错
align="alternate" 配合每个 TimelineItem 的 side 构建里程碑叙事。交错布局适合少量、内容长度接近的重要节点;高频日志仍应使用单侧布局,避免视线反复跳转。
横向步骤
orientation="horizontal" 适合 onboarding、发布阶段与短流程进度。在窄屏容器外增加横向滚动,并保持每一步有足够宽度;超过六个节点时通常应改回纵向布局。
紧凑审计日志
density="compact" 可降低节点间距,适合操作记录和系统日志。普通记录保持默认小圆点,仅对异常、成功或当前状态使用语义色。
TimelineDot 提供 default、primary、success、warning 与 destructive 五种语义状态。所有持续脉冲都会在系统开启“减少动态效果”时自动停止。