AI 参与说明:本文由 Agent
Codex、article_outline、content_audit与css_module_architecture根据公开资料辅助整理;示例使用虚构的通用内容,不包含非公开素材、业务名称或数据。文中的实现以 Vite + React 为例,资料链接于 2026-08-12 核验;请结合自己的构建工具与页面基础样式验证。
结论先说#
这类效果的关键不是给每张卡片画一条 L 形线,而是把它拆成两层:
- 一条独立、连续的浅灰色主轴。
- 每张卡片各自的一段粉色分支;分支只画左边框和下边框,再给左下角加圆角。
对于按顺序排列的单列卡片,优先选择 CSS Module + ::before:不需要 JavaScript 测量卡片高度,文字换行后连接线仍会随卡片自然伸缩。
| 方案 | 适用场景 | 主要限制 |
|---|---|---|
| CSS Module + 伪元素 | 单列、按阅读顺序排列的卡片 | 不适合交叉线或任意父子关系 |
SVG <path> 覆盖层 | 多分支、精确曲线、路径动画 | 响应式布局变化时通常要重新计算路径 |
| Canvas | 大量节点与高频重绘 | 文本选择、无障碍和调试成本较高 |
| 图编辑器 / 布局引擎 | 拖拽、缩放、自动布局的关系图 | 依赖与交互复杂度明显更高 |
先把视觉结构拆对#
主轴应是一个单独的绝对定位元素,而不是由每个卡片的分支拼出来。这样它会始终保持一条细、直、连续的灰线。
每个卡片的 ::before 从卡片左边向左延伸到主轴:
position: relative的时间线容器建立主轴的坐标系;每个卡片项再建立分支的坐标系。- 分支使用
border-left与border-bottom,没有border-top、border-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 项目。它们没有 html、body、: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、*、button、li 等全局规则,以及继承下来的字体、颜色,仍可能影响组件。需要真正避开旧页面全局样式时,应让该页面成为独立入口,或先清理宿主的全局规则;不要误以为改成 .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 源文件不能直接被浏览器识别;要么经过构建,要么改用明确的页面根类前缀。