import type { CSSProperties } from "react"
import {
FileText,
GitPullRequest,
Hammer,
Rocket,
TriangleAlert,
} from "lucide-react"
import { StatProgress } from "@/components/ui/stat-card/stat-card"
import {
Timeline,
TimelineAttachment,
TimelineConnector,
TimelineContent,
TimelineItem,
TimelineMarker,
TimelineTime,
TimelineTitle,
} from "@/components/ui/timeline/timeline"
// 标记按事件类型着色;done / current / upcoming 由实线、脉冲光环、虚线区分,不只靠颜色
function Avatar({ children, tone }: { children: string; tone: string }) {
return (
<span
className="flex size-6 items-center justify-center rounded-full border-2 border-card bg-(--tone)/15 font-mono text-[10px] text-(--tone)"
style={{ "--tone": `var(--flow-${tone})` } as CSSProperties}
>
{children}
</span>
)
}
export default function TimelineDemo() {
return (
<div className="w-full max-w-lg overflow-hidden rounded-lg border border-edge bg-card">
<div className="flex items-center justify-between gap-3 border-b border-edge px-4 py-3 sm:px-6">
<span className="text-sm font-medium">发布流水线</span>
<span className="font-mono text-xs text-muted-foreground">
#142 · 进行中
</span>
</div>
<Timeline aria-label="发布流水线" className="px-4 py-4 sm:px-6">
<TimelineItem>
<TimelineMarker tone="violet" label="代码评审">
<GitPullRequest />
</TimelineMarker>
<TimelineConnector />
<TimelineContent className="pb-4">
<div className="flex items-baseline justify-between gap-3">
<TimelineTitle>评审通过,已合并</TimelineTitle>
<TimelineTime dateTime="2026-10-27T10:02:00+08:00">
10:02
</TimelineTime>
</div>
<TimelineAttachment className="mt-2">
<div
className="flex -space-x-1.5"
aria-label="评审人:林川、许言"
>
<Avatar tone="violet">LC</Avatar>
<Avatar tone="blue">XY</Avatar>
</div>
<span className="text-muted-foreground">2 位评审人通过</span>
</TimelineAttachment>
</TimelineContent>
</TimelineItem>
<TimelineItem>
<TimelineMarker tone="amber" label="告警">
<TriangleAlert />
</TimelineMarker>
<TimelineConnector />
<TimelineContent className="pb-4">
<div className="flex items-baseline justify-between gap-3">
<TimelineTitle>缓存命中率偏低</TimelineTitle>
<TimelineTime dateTime="2026-10-27T10:11:00+08:00">
10:11
</TimelineTime>
</div>
<TimelineAttachment className="mt-2">
<span className="inline-flex items-center gap-1.5 rounded-md border border-edge bg-muted/30 px-2 py-1 font-mono">
<FileText
className="size-3.5 text-muted-foreground"
aria-hidden="true"
/>
cache.log<span className="text-muted-foreground">18 KB</span>
</span>
<span className="text-muted-foreground">已清理并自动重试</span>
</TimelineAttachment>
</TimelineContent>
</TimelineItem>
<TimelineItem status="current">
<TimelineMarker tone="blue" status="current" label="构建中">
<Hammer />
</TimelineMarker>
<TimelineConnector />
<TimelineContent className="pb-4">
<div className="flex items-baseline justify-between gap-3">
<TimelineTitle>正在构建站点</TimelineTitle>
<TimelineTime dateTime="2026-10-27T10:16:00+08:00">
现在
</TimelineTime>
</div>
<div className="mt-2.5 flex items-center gap-3">
<StatProgress
value={68}
tone="blue"
size="sm"
label="构建进度 68%"
/>
<span className="shrink-0 font-mono text-xs whitespace-nowrap text-muted-foreground tabular-nums">
68%
</span>
</div>
</TimelineContent>
</TimelineItem>
<TimelineItem status="upcoming">
<TimelineMarker status="upcoming" label="待发布">
<Rocket />
</TimelineMarker>
<TimelineContent>
<div className="flex items-baseline justify-between gap-3">
<TimelineTitle className="text-muted-foreground">
发布到生产环境
</TimelineTitle>
<TimelineTime>待构建完成</TimelineTime>
</div>
</TimelineContent>
</TimelineItem>
</Timeline>
</div>
)
}
安装
pnpm dlx shadcn@latest add https://yiui.dev/r/timeline.json用法
Timeline 是有序列表;每项由标记、连接线和内容组成。把 TimelineTime 放进 TimelineItem.time,就能得到左侧的窄编号栏。把它放在标题旁,则是活动流的布局。
import {
Timeline,
TimelineItem,
TimelineMarker,
TimelineConnector,
TimelineContent,
TimelineTitle,
TimelineTime,
TimelineDescription,
} from "@/components/ui/timeline/timeline"
export function TimelineExample() {
return (
<Timeline aria-label="项目里程碑">
<TimelineItem time={<TimelineTime dateTime="2026">2026</TimelineTime>}>
<TimelineMarker>01</TimelineMarker>
<TimelineConnector />
<TimelineContent>
<TimelineTitle>发布第一个版本</TimelineTitle>
<TimelineDescription>
把反复使用的交互整理成组件。
</TimelineDescription>
</TimelineContent>
</TimelineItem>
</Timeline>
)
}示例
经历
给 TimelineItem 传 time,标记左边多出一列窄的时间栏,适合年份、日期这类用来定位的信息。标记不放内容时默认是一个小圆点。
import {
Timeline,
TimelineConnector,
TimelineContent,
TimelineDescription,
TimelineItem,
TimelineMarker,
TimelineTime,
TimelineTitle,
} from "@/components/ui/timeline/timeline"
const EXPERIENCE = [
{
year: "2026",
title: "维护个人站与组件库",
detail: "把反复写的交互整理成可以安装的组件。",
},
{
year: "2024",
title: "独立开发",
detail: "接前端外包,也维护几个开源小项目,主要用 React 和 Next.js。",
},
{
year: "2021",
title: "前端工程师",
detail: "在一个数据产品团队,负责仪表盘和图表组件。",
},
{
year: "2019",
title: "开始写代码",
detail: "从一个静态博客开始。",
},
]
export default function TimelineExperienceDemo() {
return (
<Timeline aria-label="个人经历" className="max-w-md">
{EXPERIENCE.map(({ year, title, detail }) => (
<TimelineItem
key={year}
time={<TimelineTime dateTime={year}>{year}</TimelineTime>}
>
<TimelineMarker />
<TimelineConnector />
<TimelineContent>
<TimelineTitle>{title}</TimelineTitle>
<TimelineDescription>{detail}</TimelineDescription>
</TimelineContent>
</TimelineItem>
))}
</Timeline>
)
}
活动流
标题与相对时间在同一行,窄屏可自然换行。附件槽可放文件胶囊、人物卡或首字母头像组。这里的相对时间是固定演示数据;真实应用需要由调用方更新。
import { FolderPlus, GitPullRequest, Rocket } from "lucide-react"
import {
Timeline,
TimelineAttachment,
TimelineConnector,
TimelineContent,
TimelineDescription,
TimelineItem,
TimelineMarker,
TimelineTime,
TimelineTitle,
} from "@/components/ui/timeline/timeline"
export default function TimelineActivityDemo() {
return (
<Timeline aria-label="工作动态" className="max-w-lg">
<TimelineItem>
<TimelineMarker tone="green">
<Rocket />
</TimelineMarker>
<TimelineConnector />
<TimelineContent>
<div className="flex flex-wrap items-baseline justify-between gap-x-3">
<TimelineTitle>部署成功</TimelineTitle>
<TimelineTime dateTime="2026-10-27T10:57:00+08:00">
3 分钟前
</TimelineTime>
</div>
<TimelineDescription>组件文档已更新到生产环境。</TimelineDescription>
<TimelineAttachment>
<span className="rounded-md border border-edge bg-muted/30 px-2 py-1.5 font-mono">
build.log
<span className="ml-2 text-muted-foreground">2.4 KB</span>
</span>
</TimelineAttachment>
</TimelineContent>
</TimelineItem>
<TimelineItem>
<TimelineMarker tone="violet">
<GitPullRequest />
</TimelineMarker>
<TimelineConnector />
<TimelineContent>
<div className="flex flex-wrap items-baseline justify-between gap-x-3">
<TimelineTitle>代码评审</TimelineTitle>
<TimelineTime dateTime="2026-10-27T10:20:00+08:00">
40 分钟前
</TimelineTime>
</div>
<TimelineDescription>
时间线组件的交互细节已确认。
</TimelineDescription>
<TimelineAttachment>
<span
aria-hidden="true"
className="flex size-7 items-center justify-center rounded-full bg-primary/10 font-mono text-[10px] text-primary"
>
LC
</span>
<span className="text-muted-foreground">林川 · 已通过</span>
</TimelineAttachment>
</TimelineContent>
</TimelineItem>
<TimelineItem>
<TimelineMarker>
<FolderPlus />
</TimelineMarker>
<TimelineContent>
<div className="flex flex-wrap items-baseline justify-between gap-x-3">
<TimelineTitle>新建项目</TimelineTitle>
<TimelineTime dateTime="2026-10-26">昨天</TimelineTime>
</div>
<TimelineDescription>开始整理下一批组件。</TimelineDescription>
</TimelineContent>
</TimelineItem>
</Timeline>
)
}
项目进度
done 的后续线段为实线,current / upcoming 的后续线段为虚线。当前项具有 aria-current="step";给标记也传入 status="current" 可开启轻微脉冲。
import { Check, Code, Flag } from "lucide-react"
import {
Timeline,
TimelineConnector,
TimelineContent,
TimelineDescription,
TimelineItem,
TimelineMarker,
TimelineTitle,
} from "@/components/ui/timeline/timeline"
const STEPS = [
{
title: "需求整理",
detail: "已完成 · 明确范围与交付清单",
icon: Check,
status: "done",
},
{
title: "组件实现",
detail: "进行中 · 补齐键盘与移动端交互",
icon: Code,
status: "current",
},
{
title: "发布上线",
detail: "待开始 · 构建、检查与文档验收",
icon: Flag,
status: "upcoming",
},
] as const
export default function TimelineProgressDemo() {
return (
<Timeline aria-label="项目进度" className="max-w-md">
{STEPS.map(({ title, detail, icon: Icon, status }) => (
<TimelineItem key={title} status={status}>
<TimelineMarker
status={status}
tone={status === "done" ? "green" : "primary"}
>
<Icon />
</TimelineMarker>
<TimelineConnector />
<TimelineContent>
<TimelineTitle>{title}</TimelineTitle>
<TimelineDescription>{detail}</TimelineDescription>
</TimelineContent>
</TimelineItem>
))}
</Timeline>
)
}
水平路线图
orientation="horizontal" 在 640px 及以上横排,窄屏退回纵向,避免压缩标题或产生横向滚动。
import {
Timeline,
TimelineConnector,
TimelineContent,
TimelineDescription,
TimelineItem,
TimelineMarker,
TimelineTime,
TimelineTitle,
} from "@/components/ui/timeline/timeline"
const RELEASES = [
{
version: "v0.1",
title: "原语搭建",
date: "2026-08",
note: "已发布",
status: "done",
},
{
version: "v0.5",
title: "组件文档",
date: "2026-09",
note: "已发布",
status: "done",
},
{
version: "v0.9",
title: "交互打磨",
date: "2026-10",
note: "正在构建",
status: "current",
},
{
version: "v1.0",
title: "正式发布",
date: "2026-12",
note: "计划中",
status: "upcoming",
},
] as const
export default function TimelineHorizontalDemo() {
return (
<Timeline
orientation="horizontal"
aria-label="版本路线图"
className="max-w-2xl"
>
{RELEASES.map(({ version, title, date, note, status }) => (
<TimelineItem key={version} status={status}>
<TimelineMarker status={status}>{version.slice(1)}</TimelineMarker>
<TimelineConnector />
<TimelineContent>
<TimelineTime dateTime={date}>{date}</TimelineTime>
<TimelineTitle>
{version} · {title}
</TimelineTitle>
<TimelineDescription>{note}</TimelineDescription>
</TimelineContent>
</TimelineItem>
))}
</Timeline>
)
}
交替更新日志
align="alternate" 在宽屏把偶数项放在轴线左侧,窄屏全部回到右侧。读取顺序始终按 DOM 的时间顺序。
import {
Timeline,
TimelineConnector,
TimelineContent,
TimelineDescription,
TimelineItem,
TimelineMarker,
TimelineTime,
TimelineTitle,
} from "@/components/ui/timeline/timeline"
const UPDATES = [
{
date: "2026-10-27",
title: "时间线与卡片堆叠",
detail: "用更轻的结构呈现经历、活动与作品。",
},
{
date: "2026-10-18",
title: "数据展示原语",
detail: "补齐指标、趋势和紧凑图表。",
},
{
date: "2026-10-09",
title: "主题系统更新",
detail: "让每一种颜色都适应亮暗环境。",
},
]
export default function TimelineAlternateDemo() {
return (
<Timeline align="alternate" aria-label="更新日志" className="max-w-2xl">
{UPDATES.map(({ date, title, detail }, index) => (
<TimelineItem key={date}>
<TimelineMarker tone={index === 0 ? "primary" : "muted"} />
<TimelineConnector />
<TimelineContent>
<TimelineTime dateTime={date}>{date}</TimelineTime>
<TimelineTitle>{title}</TimelineTitle>
<TimelineDescription>{detail}</TimelineDescription>
</TimelineContent>
</TimelineItem>
))}
</Timeline>
)
}
API
Timeline
orientation 默认 vertical,align 默认 start。alternate 仅对竖向布局生效。支持原生 ol 属性,包括 aria-label。
TimelineOrientation | undefined仅竖向时间线在宽屏交替排列。
类型TimelineAlign | undefinedTimelineItem
status 默认 done,同时控制连接线和标记的基础状态。time 是左侧编号栏的插槽;水平模式下放到标记上方。
TimelineStatus | undefined左侧编号栏;建议传入 TimelineTime。
类型React.ReactNodeTimelineMarker
默认显示圆点,也可放图标或数字。tone 支持 primary / foreground / muted / destructive 和六个 --flow-* 色调;status 可覆盖外层状态。装饰标记默认对读屏隐藏,有独立语义时使用 label。
"current" | "done" | "upcoming" | null | undefinedFlowToneName | undefined有语义的标记请提供文字说明。
类型string | undefinedTimelineConnector
放在 TimelineItem 的直接子级,自动延伸至下一项;最后一项的连接线自动隐藏。
TimelineConnector 没有自定义 Props;该组件继承原生元素的全部属性。
TimelineContent
内容列,最后一项自动移除底部留白。
TimelineContent 没有自定义 Props;该组件继承原生元素的全部属性。
TimelineTitle
默认使用 h3,继承原生标题属性。
TimelineTitle 没有自定义 Props;该组件继承原生元素的全部属性。
TimelineTime
原生 time 元素,等宽数字。显示文本由调用方提供,dateTime 使用机器可读日期。
TimelineTime 没有自定义 Props;该组件继承原生元素的全部属性。
TimelineDescription
13px 正文,宽松行高,继承原生 p 属性。
TimelineDescription 没有自定义 Props;该组件继承原生元素的全部属性。
TimelineAttachment
可换行的内容槽;内部可以放链接或按钮,交互语义由调用方提供。
TimelineAttachment 没有自定义 Props;该组件继承原生元素的全部属性。
可访问性
- 使用有序列表表达事件顺序;标题、日期与说明保留原生语义。
- 当前阶段通过
aria-current表达;状态同时使用文字或图标说明。 - 系统启用“减少动态效果”时,入场位移与脉冲停止。
- 内容初始可见,CSS 入场动画只移动位置,无 JavaScript 时仍能阅读。
注意事项
- 标记、连接线、内容应是
TimelineItem的直接子级,不额外包一层布局容器。 - 交替与水平布局使用视口断点;嵌入很窄的桌面容器时建议使用默认纵向布局。
timeline.css随源码安装,不要移除入口文件对它的导入。多彩变量由flow-diagram依赖提供。
源码
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import {
flowToneStyle,
type FlowToneName,
} from "@/components/ui/flow-diagram/tone"
import "./timeline.css"
type TimelineStatus = "done" | "current" | "upcoming"
type TimelineOrientation = "vertical" | "horizontal"
type TimelineAlign = "start" | "alternate"
function Timeline({
className,
orientation = "vertical",
align = "start",
...props
}: React.ComponentProps<"ol"> & {
orientation?: TimelineOrientation
/** 仅竖向时间线在宽屏交替排列。 */
align?: TimelineAlign
}) {
return (
<ol
data-slot="timeline"
data-orientation={orientation}
data-align={align}
className={cn("isolate m-0 w-full list-none p-0", className)}
{...props}
/>
)
}
function TimelineItem({
className,
status = "done",
time,
children,
...props
}: React.ComponentProps<"li"> & {
status?: TimelineStatus
/** 左侧编号栏;建议传入 TimelineTime。 */
time?: React.ReactNode
}) {
return (
<li
data-slot="timeline-item"
data-status={status}
data-time={time ? "column" : undefined}
aria-current={status === "current" ? "step" : undefined}
className={cn("group/timeline-item relative min-w-0", className)}
{...props}
>
{time ? <div data-slot="timeline-item-time">{time}</div> : null}
{children}
</li>
)
}
const timelineMarkerVariants = cva(
"relative z-10 flex size-8 shrink-0 items-center justify-center self-start rounded-full border border-edge bg-background font-mono text-xs text-(--flow-tone) [&_svg]:size-3.5",
{
variants: {
status: {
done: "",
current: "ring-4 ring-(--flow-tone)/10 motion-safe:animate-pulse",
upcoming: "border-dashed text-muted-foreground",
},
},
defaultVariants: { status: "done" },
},
)
function TimelineMarker({
className,
tone = "primary",
status,
style,
children,
label,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof timelineMarkerVariants> & {
tone?: FlowToneName
/** 有语义的标记请提供文字说明。 */
label?: string
}) {
return (
<span
data-slot="timeline-marker"
data-status={status ?? undefined}
role={label ? "img" : undefined}
aria-label={label}
aria-hidden={label ? undefined : true}
className={cn(timelineMarkerVariants({ status }), className)}
style={{ ...flowToneStyle(tone), ...style }}
{...props}
>
{children ?? (
<span
data-slot="timeline-dot"
className="size-2 rounded-full bg-current"
/>
)}
</span>
)
}
function TimelineConnector({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="timeline-connector"
aria-hidden="true"
className={cn("pointer-events-none border-edge", className)}
{...props}
/>
)
}
function TimelineContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="timeline-content"
className={cn("min-w-0 pt-1 pb-8", className)}
{...props}
/>
)
}
function TimelineTitle({ className, ...props }: React.ComponentProps<"h3">) {
return (
<h3
data-slot="timeline-title"
className={cn(
"m-0 text-sm leading-6 font-medium tracking-tight",
className,
)}
{...props}
/>
)
}
function TimelineTime({ className, ...props }: React.ComponentProps<"time">) {
return (
<time
data-slot="timeline-time"
className={cn(
"shrink-0 font-mono text-xs leading-6 text-muted-foreground tabular-nums",
className,
)}
{...props}
/>
)
}
function TimelineDescription({
className,
...props
}: React.ComponentProps<"p">) {
return (
<p
data-slot="timeline-description"
className={cn(
"mt-1 mb-0 text-[13px] leading-7 text-muted-foreground",
className,
)}
{...props}
/>
)
}
function TimelineAttachment({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="timeline-attachment"
className={cn(
"mt-3 flex flex-wrap items-center gap-2 text-xs",
className,
)}
{...props}
/>
)
}
export {
Timeline,
TimelineItem,
TimelineMarker,
TimelineConnector,
TimelineContent,
TimelineTitle,
TimelineTime,
TimelineDescription,
TimelineAttachment,
timelineMarkerVariants,
}
export type { TimelineStatus, TimelineOrientation, TimelineAlign }
[data-slot="timeline-item"] {
display: grid;
grid-template-columns: 2rem minmax(0, 1fr);
column-gap: 1rem;
animation: timeline-enter 250ms ease-out both;
}
[data-slot="timeline-marker"] {
grid-column: 1;
grid-row: 1;
}
[data-slot="timeline-content"] {
grid-column: 2;
grid-row: 1;
}
[data-slot="timeline-item-time"] {
padding-top: 0.25rem;
grid-column: 1;
grid-row: 1;
}
[data-slot="timeline-item"][data-time="column"] {
grid-template-columns: 3.5rem 2rem minmax(0, 1fr);
}
[data-time="column"] > [data-slot="timeline-marker"] {
grid-column: 2;
}
[data-time="column"] > [data-slot="timeline-content"] {
grid-column: 3;
}
[data-slot="timeline-connector"] {
position: absolute;
left: 1rem;
top: 2rem;
bottom: 0;
border-left-width: 1px;
}
[data-time="column"] > [data-slot="timeline-connector"] {
left: 5.5rem;
}
[data-slot="timeline-item"]:last-child > [data-slot="timeline-connector"] {
display: none;
}
[data-slot="timeline-item"]:last-child > [data-slot="timeline-content"] {
padding-bottom: 0;
}
[data-slot="timeline-item"][data-status="current"]
> [data-slot="timeline-connector"],
[data-slot="timeline-item"][data-status="upcoming"]
> [data-slot="timeline-connector"] {
border-style: dashed;
}
[data-slot="timeline-item"][data-status="current"]
> [data-slot="timeline-marker"]:not([data-status]) {
outline: 4px solid color-mix(in oklch, var(--flow-tone) 12%, transparent);
}
[data-slot="timeline-item"][data-status="upcoming"]
> [data-slot="timeline-marker"]:not([data-status]) {
border-style: dashed;
color: var(--muted-foreground);
}
@media (min-width: 640px) {
[data-slot="timeline"][data-orientation="horizontal"] {
display: flex;
}
[data-orientation="horizontal"] > [data-slot="timeline-item"] {
flex: 1;
display: flex;
flex-direction: column;
gap: 1rem;
}
[data-orientation="horizontal"]
> [data-slot="timeline-item"]
> [data-slot="timeline-content"] {
padding: 0 1rem 0 0;
}
[data-orientation="horizontal"]
> [data-slot="timeline-item"]
> [data-slot="timeline-connector"] {
top: 1rem;
left: 2rem;
right: 0;
bottom: auto;
border-left-width: 0;
border-top-width: 1px;
}
[data-orientation="vertical"][data-align="alternate"]
> [data-slot="timeline-item"] {
grid-template-columns: minmax(0, 1fr) 2rem minmax(0, 1fr);
}
[data-orientation="vertical"][data-align="alternate"]
> [data-slot="timeline-item"]
> [data-slot="timeline-marker"] {
grid-column: 2;
}
[data-orientation="vertical"][data-align="alternate"]
> [data-slot="timeline-item"]
> [data-slot="timeline-content"] {
grid-column: 3;
}
[data-orientation="vertical"][data-align="alternate"]
> [data-slot="timeline-item"]:nth-child(even)
> [data-slot="timeline-content"] {
grid-column: 1;
text-align: right;
}
[data-orientation="vertical"][data-align="alternate"]
> [data-slot="timeline-item"]
> [data-slot="timeline-connector"] {
left: 50%;
}
[data-orientation="vertical"][data-align="alternate"]
> [data-slot="timeline-item"]
> [data-slot="timeline-item-time"] {
grid-column: 1;
text-align: right;
}
[data-orientation="vertical"][data-align="alternate"]
> [data-slot="timeline-item"]:nth-child(even)
> [data-slot="timeline-item-time"] {
grid-column: 3;
text-align: left;
}
}
@keyframes timeline-enter {
from {
transform: translateY(6px);
}
to {
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
[data-slot="timeline-item"] {
animation: none;
}
}