用 CSS 实现带圆弧连接线的时间线卡片

This article is extracted from the chat log with AI. Please identify it with caution.

AI 参与说明:本文由 Agent Codexarticle_outlinecontent_auditcss_module_architecturecss_geometryscreenshot_compareyear_path_audittailwind_review 根据公开资料辅助整理;示例使用虚构的通用内容,不包含非公开素材、业务名称或数据。在线页面以 Vite + Tailwind CSS 构建,文中的组件片段用 React JSX 表达;资料链接于 2026-08-12 核验,请结合自己的构建工具与页面基础样式验证。

结论先说#

这类效果的关键不是给每张卡片画一条 L 形线,而是把它拆成三个独立图层:

  1. 一条全高、连续的浅灰色底轨。
  2. 一条从标题底部连续延伸到最后一个连接点的粉色活动轨。
  3. 每张卡片各自的一段粉色分支;分支只画左边框和下边框,再给左下角加圆角。

对于按顺序排列的单列卡片,优先选择 Tailwind utility + ::before。若只需要灰色主轴和圆弧分支,完全不需要 JavaScript;若粉色活动轨必须精确停在最后一张卡片的中线,用 ResizeObserver 写入两个局部 CSS 变量即可。

方案适用场景主要限制
Tailwind utility + 伪元素单列、按阅读顺序排列的卡片不适合交叉线或任意父子关系
SVG <path> 覆盖层多分支、精确曲线、路径动画响应式布局变化时通常要重新计算路径
Canvas大量节点与高频重绘文本选择、无障碍和调试成本较高
图编辑器 / 布局引擎拖拽、缩放、自动布局的关系图依赖与交互复杂度明显更高

先把视觉结构拆对#

灰色底轨应是一个单独的绝对定位元素,而不是由每个卡片的分支拼出来。参考图中的粉色主轴同样应独立绘制,不能依赖每个 ::before 的左边框接力,否则会在卡片内部露出灰线,形成粉灰交替的断轴。

每个卡片的 ::before 从卡片左边向左延伸到主轴:

  • position: relative 的时间线容器建立主轴的坐标系;每个卡片项再建立分支的坐标系。
  • 分支使用 border-leftborder-bottom,没有 border-topborder-right
  • border-bottom-left-radius 把直角变成 1/4 圆弧;卡片自身的圆角与这件事无关。
  • 灰色底轨在第 0 层,分支在第 0 层,连续粉色活动轨在第 1 层,标题和卡片在第 2 层。
  • 分支左边框与 --rail-x 对齐;它与粉色活动轨重合,不会在主轴旁边出现两条并行竖线。

::before 是生成内容,适合这类纯装饰线条;步骤的含义仍放在 ol > li > article 中,不能依赖伪元素传递信息。MDN 对 ::before 的说明也要求伪元素有 content 才能生成。

必要的 Tailwind 片段#

在线示例使用 Tailwind CSS v4 的 Vite plugin,在构建期扫描 utility class 并输出静态 CSS。Tailwind:Vite 安装 推荐使用 @tailwindcss/vite。为避免一个 demo 额外施加全局 reset,示例只导入 theme 与 utilities,不导入 Preflight;这是 Tailwind 官方提供的禁用 Preflight 方式。Tailwind:Preflight

/* src/tailwind.css */
@layer theme, base, components, utilities;
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/utilities.css" layer(utilities);

下面是核心的 JSX 表达。[--rail-x:42px] 这类 arbitrary property 保留了几何参数;before: variants 生成圆弧分支,因此不必再手写组件 CSS。

<section
  className="relative isolate [--rail-x:42px] [--card-start:88px] [--gap:24px]"
  aria-labelledby={titleId}
>
  <span className="pointer-events-none absolute inset-y-0 left-[var(--rail-x)] z-0 w-[2px] bg-[#dce0e6]" />
  <span className="pointer-events-none absolute left-[var(--rail-x)] top-[var(--active-rail-start,0px)] z-[1] h-[var(--active-rail-length,0px)] w-[2px] bg-[#f1b4d5]" />
  <h2 className="relative z-[2] m-0 inline-block box-border rounded-[10px] bg-[#fff8fb] px-6 py-[14px] text-[#9d1c67]">{title}</h2>

  <ol className="relative m-0 ml-[var(--card-start)] mt-[26px] grid box-border list-none gap-[var(--gap)] p-0">
    {items.map((item) => (
      <li
        key={item.id}
        className="relative box-border min-w-0 before:pointer-events-none before:absolute before:right-full before:top-[calc(var(--gap)*-1)] before:h-[calc(50%+var(--gap))] before:w-[calc(var(--card-start)-var(--rail-x))] before:rounded-bl-[22px] before:border-b-2 before:border-l-2 before:border-[#f1b4d5] before:content-['']"
      >
        <article className="relative z-[2] box-border rounded-[20px] bg-[#fff8fb] px-6 py-5">{item.title}</article>
      </li>
    ))}
  </ol>
</section>

粉色活动轨的两个端点要跟随内容重排。在线示例用下面的短函数写入局部变量,timelineheadinglastCard 分别对应上面的时间线容器、标题和最后一张卡片;在 React 中可用 ref 获取它们。

function syncActiveRail(timeline, heading, lastCard) {
  const root = timeline.getBoundingClientRect();
  const start = heading.getBoundingClientRect().bottom - root.top;
  const last = lastCard.getBoundingClientRect();
  const end = last.top - root.top + last.height / 2;

  timeline.style.setProperty("--active-rail-start", `${start}px`);
  timeline.style.setProperty("--active-rail-length", `${end - start}px`);
}

const observer = new ResizeObserver(() => syncActiveRail(timeline, heading, lastCard));
[timeline, heading, lastCard].forEach((element) => observer.observe(element));
syncActiveRail(timeline, heading, lastCard);

这里最容易写错的是几何关系:卡片起点为 --card-start,主轴为 --rail-x,所以分支宽度必须是两者之差。right: 100% 让分支从卡片左边缘向左画,分支的左侧刚好落在主轴上。绝对定位元素会以最近的定位祖先作为 containing block;这一点可参阅 MDN:containing blockResizeObserver 只负责粉色活动轨的起止位置,不负责逐张卡片画线。

Tailwind 与 iframe 的隔离边界#

当前示例直接使用 Tailwind utility class,并在构建期输出静态 CSS。Tailwind 不是 Shadow DOM;若把 demo 嵌进已有页面,仍可能受到 body*buttonli 等全局规则与继承属性影响。

因此示例将 Tailwind bundle 放进独立 iframe document:博客主题的 CSS 和主题变量不会进入 demo,Tailwind 生成的变量也只存在于 iframe 内。这比只给组件加根类前缀更可靠。

何时改用 SVG#

如果一张卡片可以连接多个节点、连接线会交叉,或需要精确控制贝塞尔曲线和动画,SVG 更合适:用 <path> 描述线路,再根据卡片实际位置更新路径。卡片会折行、展开或窗口缩放时,应使用 ResizeObserver 或布局计算重新生成坐标;下面的 d 只是路径语法示意,不能直接套到动态布局。

<path d="M24 0 V44 Q24 60 40 60 H88" fill="none" stroke="#efafd1" stroke-width="2" />

SVG path 的命令与路径数据格式可参考 MDN:SVG Paths

在线预览#

下面是由 Vite 构建、使用 Tailwind CSS 的独立 HTML 页面。它与文章处于同一站点,但 iframe 是独立 document,因此博客主题的样式不会进入示例页面;窄屏下可使用后面的独立链接查看。

在新页面打开示例

验证清单#

  • 灰色底轨全高连续;粉色活动轨从标题底部连续到最后一个圆弧的中线,之后才重新露出灰色底轨。
  • 卡片文本换行、首项、末项、窄屏和 200% 缩放下,圆弧不应被截断。
  • 不要在时间线祖先上随意设置 overflow: hidden,否则分支或键盘 focus ring 可能被裁掉。
  • 用浏览器开发者工具检查层级:.item::before 在第 0 层,.activeRail 在第 1 层,标题和卡片在第 2 层。
  • Tailwind 源文件和 Vite 源码都不能直接部署为演示页;应部署构建出的 index.html 与 assets,而不是依赖浏览器解析构建期指令。

延伸阅读与资料#

本文共 2332 字,创建于 Aug 12, 2026

相关标签: Frontend, CSS, ByAI