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

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

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

结论先说#

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

  1. 一条独立、连续的浅灰色主轴。
  2. 每张卡片各自的一段粉色分支;分支只画左边框和下边框,再给左下角加圆角。

对于按顺序排列的单列卡片,优先选择 CSS Module + ::before:不需要 JavaScript 测量卡片高度,文字换行后连接线仍会随卡片自然伸缩。

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

先把视觉结构拆对#

主轴应是一个单独的绝对定位元素,而不是由每个卡片的分支拼出来。这样它会始终保持一条细、直、连续的灰线。

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

  • position: relative 的时间线容器建立主轴的坐标系;每个卡片项再建立分支的坐标系。
  • 分支使用 border-leftborder-bottom,没有 border-topborder-right
  • border-bottom-left-radius 把直角变成 1/4 圆弧;卡片自身的圆角与这件事无关。
  • 分支左边框与 --rail-x 对齐,粉线会覆盖主轴对应的一小段,不会在主轴旁边出现两条并行竖线。

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

最小可复现的 CSS Module 示例#

Vite 会把以 .module.css 结尾的文件视为 CSS Modules,导入结果是类名映射对象;组件通过 styles.card 使用这些局部类名。Vite:CSS Modules

下面的两个文件可直接放进一个 Vite + React 项目。它们没有 htmlbody:root:global 或裸 * 选择器;页面背景和全局 reset 仍由宿主应用负责。

// RoundedTimeline.jsx
import styles from "./RoundedTimeline.module.css";

export function RoundedTimeline({ id, title, items }) {
  const titleId = `${id}-title`;

  return (
    <section className={styles.timeline} aria-labelledby={titleId}>
      <span className={styles.rail} aria-hidden="true" />
      <h2 className={styles.heading} id={titleId}>{title}</h2>

      <ol className={styles.list}>
        {items.map((item) => (
          <li className={styles.item} key={item.id}>
            <article className={styles.card}>
              <strong>{item.title}</strong>
              <span className={styles.description}>{item.description}</span>
            </article>
          </li>
        ))}
      </ol>
    </section>
  );
}

// 使用示例:
// <RoundedTimeline id="release" title="发布计划" items={[
//   { id: "scope", title: "确认范围", description: "明确本次发布内容。" },
//   { id: "build", title: "完成实现", description: "通过核心功能检查。" },
//   { id: "check", title: "验收验证", description: "记录结果并安排上线。" },
// ]} />
/* RoundedTimeline.module.css */
.timeline {
  --rail-x: 40px;
  --card-start: 88px;
  --gap: 20px;
  --rail-color: #d9dee7;
  --branch-color: #efafd1;

  position: relative;
  isolation: isolate;
  box-sizing: border-box;
  padding: 8px 20px 32px 0;
  color: #24252b;
}

.rail {
  position: absolute;
  z-index: 0;
  top: 0;
  bottom: 0;
  left: var(--rail-x);
  width: 1px;
  background: var(--rail-color);
}

.heading,
.list,
.item,
.card {
  box-sizing: border-box;
}

.heading {
  margin: 0 0 18px;
  font-size: 1.25rem;
}

.list {
  position: relative;
  z-index: 1;
  display: grid;
  gap: var(--gap);
  margin: 0;
  padding: 0 0 0 var(--card-start);
  list-style: none;
}

.item {
  position: relative;
  min-width: 0;
}

.item::before {
  position: absolute;
  z-index: 0;
  top: calc(-1 * var(--gap));
  right: 100%;
  width: calc(var(--card-start) - var(--rail-x));
  height: calc(50% + var(--gap));
  box-sizing: border-box;
  border-bottom: 2px solid var(--branch-color);
  border-left: 2px solid var(--branch-color);
  border-bottom-left-radius: 18px;
  content: "";
  pointer-events: none;
}

.item:first-child::before {
  top: 0;
  height: 50%;
}

.card {
  position: relative;
  z-index: 1;
  padding: 20px 24px;
  border-radius: 18px;
  background: #fff8fb;
  line-height: 1.65;
}

.description {
  display: block;
  margin-top: 4px;
  color: #555963;
}

@media (max-width: 560px) {
  .timeline {
    --rail-x: 20px;
    --card-start: 54px;
    --gap: 16px;
  }

  .card {
    padding: 16px 18px;
    border-radius: 16px;
  }
}

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

CSS Module 能隔离什么,不能隔离什么#

CSS Module 比仅用一个根类前缀更可靠:构建工具会把本模块的类名变成局部标识,降低与别的组件同名碰撞的概率。

但它不是 Shadow DOM。已有页面里的 body*buttonli 等全局规则,以及继承下来的字体、颜色,仍可能影响组件。需要真正避开旧页面全局样式时,应让该页面成为独立入口,或先清理宿主的全局规则;不要误以为改成 .module.css 就能隔离整个 CSS cascade。

何时改用 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

验证清单#

  • 主轴只有一条连续的灰线;粉色竖线必须和主轴使用同一条 x 坐标。
  • 卡片文本换行、首项、末项、窄屏和 200% 缩放下,圆弧不应被截断。
  • 不要在时间线祖先上随意设置 overflow: hidden,否则分支或键盘 focus ring 可能被裁掉。
  • 用浏览器开发者工具检查 .item::before:它应在主轴之上、卡片之下。
  • 如果示例被放进纯静态 HTML,CSS Module 源文件不能直接被浏览器识别;要么经过构建,要么改用明确的页面根类前缀。

延伸阅读与资料#

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

相关标签: Frontend, CSS, ByAI