跳至正文
组件

Timeline时间线

可组合的时间线原语,支持经历、活动流、项目进度、水平路线图与交替更新日志。

正在加载…

安装

pnpm dlx shadcn@latest add https://yiui.dev/r/timeline.json
会连带安装 本站条目 grid-layout、flow-diagram。已存在的文件会询问是否覆盖。

用法

Timeline 是有序列表;每项由标记、连接线和内容组成。把 TimelineTime 放进 TimelineItem.time,就能得到左侧的窄编号栏。把它放在标题旁,则是活动流的布局。

components/timeline-example.tsx
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,标记左边多出一列窄的时间栏,适合年份、日期这类用来定位的信息。标记不放内容时默认是一个小圆点。

正在加载…

活动流

标题与相对时间在同一行,窄屏可自然换行。附件槽可放文件胶囊、人物卡或首字母头像组。这里的相对时间是固定演示数据;真实应用需要由调用方更新。

正在加载…

项目进度

done 的后续线段为实线,current / upcoming 的后续线段为虚线。当前项具有 aria-current="step";给标记也传入 status="current" 可开启轻微脉冲。

正在加载…

水平路线图

orientation="horizontal" 在 640px 及以上横排,窄屏退回纵向,避免压缩标题或产生横向滚动。

正在加载…

交替更新日志

align="alternate" 在宽屏把偶数项放在轴线左侧,窄屏全部回到右侧。读取顺序始终按 DOM 的时间顺序。

正在加载…

API

Timeline

orientation 默认 vertical,align 默认 start。alternate 仅对竖向布局生效。支持原生 ol 属性,包括 aria-label。

TimelineItem

status 默认 done,同时控制连接线和标记的基础状态。time 是左侧编号栏的插槽;水平模式下放到标记上方。

TimelineMarker

默认显示圆点,也可放图标或数字。tone 支持 primary / foreground / muted / destructive 和六个 --flow-* 色调;status 可覆盖外层状态。装饰标记默认对读屏隐藏,有独立语义时使用 label。

TimelineConnector

放在 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 依赖提供。

源码

components/ui/timeline/timeline.tsx
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 }
components/ui/timeline/timeline.css
[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;
  }
}