我最近做了一个产品发布页,设计稿里有一行文案要求“顺着画面里那条虚线轨道绕出去”。刚开始我觉得不难,无非是排几个字,结果真写起来才发现,普通流式排版根本做不到让文字贴着任意曲线走,而且动效一多,浏览器性能就开始欺负人。断断续续调了两天,我把能用的方案和之前草稿箱里的试验重新筛了一遍,最后整理出 8 个 CSS 和 JavaScript 代码片段,专门解决“文本路径”这一类创意排版需求。
“文本路径”这个词听着有点学院派,实际做落地页、活动页时你会经常遇到:标题不是横平竖直地排在盒子里,而是像被一条看不见的轨道推着走,或者要沿着某条山路、导视线的方向出现。这类效果对日常前端来说不算高频,但真碰到设计师发来“文字沿曲线排列”的稿子时,很多人的第一反应是直接引一个动画库,或者翻出厚重的 SVG 教程从头啃。其实多数页面需要的东西没有那么多,用 CSS 加少量原生 JavaScript,完全可以覆盖八成场景。
这份总结适合谁?适合正在做活动页、落地页、品牌介绍页的开发者;适合对 SVG 只了解基础、不想引入整套重型动画框架的人;也适合想给“普通的标题”加一点曲线感但不清楚从哪下手的新手。我会把 8 个代码片段按 CSS 优先和 JavaScript 介入分成两类,不只贴代码,还讲清楚每一个到底能做什么、为什么这么写、在真机上会踩哪些坑。
1. 这次为什么会收集“文本路径”这组片段
1.1 “文本路径”到底是什么
先说一个容易被忽略的点:Web 上最普通的文字,并不是“自由”的。一张页面在浏览器里解析后,文本默认会沿着一个矩形区域从左到右排开,碰到行尾再折到下一行。这个矩形排版模型对阅读非常友好,但如果你想做“创意文本路径”,它就成了第一道拦路虎,因为文字不会天然按你画好的贝塞尔曲线走。
要实现“让文字沿路径走”,业界其实只有几条主流技术路线。第一种是 SVG 的 <textPath>,把文字挂到一条 <path> 上,文字会像火车一样沿着铁轨方向排布。第二种是把一段字符串按字符拆开,用 CSS transform 或者 JavaScript 给每个字单独设置位置和角度,自己“模拟”出一条路径。第三种是 CSS offset-path,让一个文本块沿着运动路径走,这个跟文字本身的字符排布关系不大,更接近“文字作为一个整体在轨道上移动”。第四种是利用 CSS 的 clip-path、mask、background-clip 等属性,在不需要真实几何路径的场景里做出视觉引导感。
我这次留下的 8 个代码片段,本质上就是这四条路线的具体化:有真路径,有模拟路径,也有视觉上的“伪路径”。做项目时先分清楚你要的是哪一种,能少走很多弯路。
1.2 我挑选片段时的三个标准
整理这组代码之前,我先经历了很长一段“什么效果都想要”的阶段。真正让我停下来的,是项目中性能和兼容性的现实问题。做了三版 demo 以后,我给自己定了三条筛选标准,分享出来供参考。
第一,能用原生 CSS 解决的,就不为一个小标题引入 GSAP 或 Three.js。动画库很强大,但为了一个标题去增加 60KB 以上依赖,在移动端并不划算。第二,静态路径和动态路径要分开考虑。一个纯排版的弧形标题,用 SVG textPath 几行代码就结束;一个要跟随滚动的路径,就必须留给 JavaScript 处理。两者混在一起写,很容易把代码改成意大利面。第三,所有效果都要有“降级方案”。比如在旧浏览器里 textPath 不生效时,页面至少要能正常显示一段横向文本,而不是直接白屏或跑版。
这些标准听起来普通,但在项目里真的救过我。尤其最后一条,很多创意标题会覆盖在首屏最重要的视觉区域,如果效果代码出了兼容问题,用户第一个看到的就是错误画面。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 八段代码的整体选型:先决定用哪一段
2.1 一张速查表看清八段代码
这 8 个代码片段不能一上来就一个一个贴,否则读者容易失去上下文。我建议先看选型表,再按项目需求挑,最后回来看详细代码。
| 编号 | 片段主题 | 核心依赖 | 适合场景 |
|---|---|---|---|
| 1 | SVG textPath 文字沿曲线排列 | SVG + CSS | 首屏标题、海报感文字、导视文案 |
| 2 | CSS offset-path 让文字块沿轨迹运动 | CSS | 飞入的短语、移动标签、强调词 |
| 3 | clip-path 制造文本“指路牌”切角 | CSS | 章节入口、按钮式大标题 |
| 4 | background-clip 文字上叠加渐变引导 | CSS | 大字标题中的重点段落突出 |
| 5 | textPath startOffset 随滚动/滑块移动 | SVG + JS | 长页面里的阅读进度提示 |
| 6 | JS 拆字并按波形路径分布 | CSS + JS | 波浪感、律动感标题 |
| 7 | SVG mask 配合路径点扫出文字 | SVG + JS | 手写轨迹、探照灯式揭示 |
| 8 | WAAPI 多段路径运动 | CSS + JS | 开场动画、分镜式文案进场 |
这里要提醒一下:第 3 和第 4 条并不是严格意义上的“几何路径排版”,它们更像是在平面设计里用颜色、切角、渐变给读者造出一条视线路径,让标题和下一屏的内容产生引导关系。很多时候设计师说的“路径感”并不是真的要文字弯曲,而是希望视觉上有一个“方向”,这两种方案往往更省事。
2.2 静态和动态的边界在哪里
表格里前四段基本是静态效果,后四段偏向动态交互。这个边界是我刻意划出来的。原因在于,一旦文本要跟随滚动、鼠标或时间轴运动,单靠 CSS 很难做到“进度可控”,你必须掌握当前进度这个变量。而进度变量放在 JavaScript 里,是通过 requestAnimationFrame 还是 IntersectionObserver 更新,会直接决定效果的流畅度和代码结构。
静态片段适合放在组件的样式中,一次性渲染;动态片段更适合封装成一个小函数或者类,把“拆字”“设置进度”“销毁监听”统一管理。我在项目中通常这样组织:主标题和装饰文字用静态片段,滚动联动和交互效果用动态片段,两类逻辑尽量不写进同一个模块。
3. 片段拆解:适合纯 CSS 优先的四种实现
3.1 片段一:SVG textPath 文字沿贝塞尔路径排列
如果你是第一次做“文本路径”,第一个该学的一定是 <textPath>。它解决的问题最直接:让我有一段文字,但不想让它按普通横排出现,而是想让它沿着任意曲线走。
先看一段最基础的结构:
html复制<svg viewBox="0 0 900 300" role="img" aria-label="沿路径排列的文字">
<defs>
<path id="curvePath" d="M 20 180 C 200 30, 500 280, 880 100" fill="none" />
</defs>
<text font-size="28" fill="#1c2b3a">
<textPath href="#curvePath">
这里写你的标题文字,它会自动沿着曲线 direction 排列
</textPath>
</text>
</svg>
关键点有两个。一是 <path id="curvePath"> 定义了文字要走的轨迹,二是 <textPath href="#curvePath"> 通过 href 指向这条轨迹。文字内容写在 textPath 内部,浏览器会自动计算每个字符的位置和旋转角度,让文字像小火车一样贴到轨道上。
写这段代码时最容易出错的是路径长短。如果文字长度超过路径长度,超出的部分会被裁掉或者显示在路径终点附近,视觉上很不自然。常见做法是把曲线的长度设计得稍微长一点,或者先用路径编辑器微调。我习惯把 d 属性里的坐标写得简单些,用两三个控制点完成曲线,而不是画非常复杂的多段线,因为文字路径需要平滑的切线方向,急转弯会让字与字之间的间距看起来非常不均匀。
要用好这个片段,还需要理解一个隐含特性:SVG 默认的坐标系原点是左上角,Y 轴向下。如果在项目中把 SVG 嵌入 HTML,文字的位置会受整个 SVG 视口影响。建议给 svg 设置 viewBox,而不是直接依赖静态 width 和 height,这样后续响应式缩放才不会让路径变形。
3.2 片段二:CSS offset-path 让短语沿着轨道飞入
有时候设计师不想让文字永远停在路径上,而是想让某句关键词“沿着轨道跑进来”。这时候 CSS 的 offset-path 比 SVG textPath 更合适,因为 textPath 解决的是字符排列,offset-path 解决的是元素整体运动。
html复制<span class="moving-word">下一幕</span>
css复制.moving-word {
offset-path: path("M 20 20 Q 300 120 680 40");
offset-rotate: auto;
animation: runAcross 2.4s cubic-bezier(0.22, 0.61, 0.36, 1) forwards;
display: inline-block;
font-size: 28px;
font-weight: 600;
}
@keyframes runAcross {
0% {
offset-distance: 0%;
opacity: 0;
}
15% {
opacity: 1;
}
100% {
offset-distance: 100%;
opacity: 1;
}
}
offset-path 的语法可以理解为:给元素设置一条运动轨道,再用 offset-distance 控制它在轨道上的进度。0% 在轨道起点,100% 在轨道终点。offset-rotate: auto 会让元素自动跟随路径切线方向旋转,非常适合做“顺着轨迹飞出”的文字。
这个属性前几年只有 Chrome 系支持得最好,现在主流浏览器基本都跟上了,但在 Safari 里偶尔会有一些版本差异。我的建议是:如果这个文字块本身有重要内容,不要只靠 offset-path 呈现,还应保留一个静态 fallback 位置。我曾经把一个页面的“立即购买”按钮放在 offset-path 动画路径上,结果在低版本浏览器里按钮完全不见,用户根本找不到入口,属于典型的“为了效果牺牲可用性”。
3.3 片段三:clip-path 打造文本“指路牌”切角
每到一个新章节,很多页面会放一个很大的栏目标题。让标题变成一块指路牌,右侧带斜切角,再配合背景的装饰线,视觉上就会产生“继续往前读”的引导力。这个效果不用 SVG,一个 clip-path 就能完成。
html复制<h2 class="chapter-sign">
下一章:从需求到方案
</h2>
css复制.chapter-sign {
display: inline-block;
max-width: 720px;
padding: 28px 56px 28px 32px;
color: #fff;
background: #1d2a44;
font-size: clamp(28px, 5vw, 56px);
font-weight: 800;
line-height: 1.2;
clip-path: polygon(0 0, 100% 0, calc(100% - 28px) 100%, 0 100%);
transform: skewX(-6deg);
}
clip-path 在这里的作用是裁掉标题背景的右下角,形成一个明显的斜切缺口。skewX(-6deg) 让整体带一点倾斜,配合右侧的切角,就像一块立在场景里的指路牌。文字本身依然是正常的文字,没有做任何变形,所以可读性完全不受影响。
我特别推荐这种写法来处理“伪路径”:它不改变文本的语义结构,屏幕阅读器读到的还是完整的文字,视觉上却实现了方向感和引导性。唯一要注意的是,clip-path 会把背景裁掉,所以不要依赖 padding 和边框贴得太近,否则文字会有被“切到”的错觉。实际使用中我给右侧留了 56px 的 padding,来保证最后一个字离斜切边缘至少有两三个字的距离。
3.4 片段四:background-clip 在文字内部制造一条渐变路线
还有一种很常见的“路径感”,不是让文字动,而是让文字内部出现一条明显的用颜色标注的“路线”。比如大标题里有几个字特别重要,希望读者的视线第一时间被吸引过去,最简单的实现是把背景设为多段渐变,再用 background-clip 把背景剪进文字里。
html复制<h1 class="route-title">这条路线的起点在 A 点</h1>
css复制.route-title {
font-size: clamp(48px, 9vw, 140px);
font-weight: 900;
letter-spacing: -0.04em;
color: transparent;
background: linear-gradient(
100deg,
#111 0%,
#111 42%,
#ff5c39 42%,
#ffb100 58%,
#111 58%,
#111 100%
);
-webkit-background-clip: text;
background-clip: text;
}
运行时你会看到几个字被橙色和黄色“点亮”,而后半段文字又回到深色,看起来就像一条视觉颜色的路线从左到右扫过了整行标题。这个方法最大的好处是文字依然可以被选中、复制,也基本不影响性能,因为它只是背景渲染,不涉及 JavaScript 或动画。
需要注意三点。第一,background-clip: text 在 Chrome 和 Safari 里需要加 -webkit- 前缀,Firefox 较新版本支持标准写法,但为了保险,我会同时写两个属性。第二,color: transparent 是必须的,如果文字颜色不是透明,渐变背景会被压住。第三,配合渐变时不要用太复杂的多段颜色,通常 2 到 4 个色标就能做出清晰的视觉节奏,色标太多反而会让文字看起来脏。
4. 片段拆解:JS 介入后能玩的四种轨迹效果
4.1 片段五:用 JavaScript 控制 textPath 的 startOffset
静态 textPath 已经让文字贴在曲线上,但如果我要做“文字随滚动进度沿着曲线移动”,就需要 JavaScript 介入。SVG 的 textPath 提供了一个名叫 startOffset 的属性,表示这段文字从路径的百分之多少开始显示。把它理解成文字在轨道上的偏移量就好。
下面的代码做了一个滑块控制文字位置的例子,思路可以直接迁移到滚动进度上。
html复制<input type="range" id="pathRange" min="0" max="100" value="0" />
<svg viewBox="0 0 900 200" role="img">
<defs>
<path id="dragPath" d="M 20 120 C 250 20, 600 190, 880 60" fill="none" />
</defs>
<text font-size="26" fill="#26394d">
<textPath id="progressText" href="#dragPath">
这段文字会像游标一样沿着路径移动
</textPath>
</text>
</svg>
javascript复制const range = document.getElementById("pathRange");
const progressText = document.getElementById("progressText");
range.addEventListener("input", () => {
const value = range.value + "%";
progressText.setAttribute("startOffset", value);
});
把 startOffset 从 0% 改成 100%,文字会沿着路径从起点一路移动到终点。配合滚动事件的实现也很直观:先拿到目标区域相对视口的位置,算出它在可视区域内的进度,再把进度映射到 0 到 100 的百分比,最后 setAttribute。要注意的坑是滚动事件触发频率很高,做这种联动测量时要多用被动监听,必要的时候配合 rAF 节流,否则在低端手机上滚动时会出现明显卡顿。
4.2 片段六:JS 拆字并按波形路径重新排列
如果说 textPath 是“官方路径方案”,那“拆字”就是前端人最灵活的野路子。它不受 SVG 坐标约束,直接在 DOM 上给每个字设置 transform,可以做正弦波、抛物线、螺旋形甚至鼠标跟随轨迹。
代码思路是用 textContent 读取原始文案,再把每个字符包进 span,最后按照字符在整个字符串中的位置计算偏移量和旋转角度:
html复制<div class="wave-headline" aria-label="波形路径上的标题文字">曲线节奏</div>
css复制.wave-headline {
font-size: clamp(40px, 8vw, 88px);
font-weight: 800;
display: flex;
flex-wrap: nowrap;
}
.wave-headline span {
display: inline-block;
transform-origin: center bottom;
transition: transform 0.3s ease;
}
javascript复制const headline = document.querySelector(".wave-headline");
const rawText = headline.textContent.trim();
headline.textContent = "";
const chars = rawText.split("");
const total = chars.length;
chars.forEach((char, index) => {
const span = document.createElement("span");
span.textContent = char === " " ? "\u00A0" : char;
const progress = index / (total - 1);
const waveOffset = Math.sin(progress * Math.PI) * -36;
const rotate = (progress - 0.5) * 30;
span.style.transform = `translateY(${waveOffset}px) rotate(${rotate}deg)`;
headline.appendChild(span);
});
这个效果的本质是利用正弦函数生成一组位置偏移量,让中间的字下沉或上浮,两端的字保持相对水平。如果你想要的是圆弧,可以把 waveOffset 改成基于圆形的 cos/sin 公式。如果想让每个字符间距更均匀,不要随便用 letter-spacing,因为每个字符宽度不一样,最终组合出来的“曲线”会有明显疏密问题;更稳的办法是在 span 之间增加固定 margin 或修改 display 的 flex 基础宽度。
拆字方案还有一个容易被忽略的问题:原始文本被替换成几十个 span 之后,屏幕阅读器读到的内容顺序虽然还是完整的,但如果页面里需要动态更新标题,更新时必须重新执行拆字逻辑,不能只改 textContent,否则会出现“所有 span 还是旧字”的尴尬情况。
4.3 片段七:SVG mask 配合路径点制造“探照灯式”文字揭示
textPath 让文字全部显示在曲线上,但如果我想让文字像被手电筒照着、一点一点亮起来,就需要结合 SVG mask 和 path 的点坐标。这里的思路是先做一个圆形遮罩,这个遮罩只允许它覆盖范围内的文字可见,然后让圆形遮罩的中心点沿路径运动。
html复制<svg viewBox="0 0 800 200">
<defs>
<path id="revealPath" d="M 20 120 C 250 30, 550 180, 780 70" fill="none" />
<mask id="spotMask">
<circle id="spot" cx="20" cy="120" r="60" fill="white" />
</mask>
</defs>
<text font-size="30" mask="url(#spotMask)">
<textPath href="#revealPath">
这段文字会跟着光点一点一点显形
</textPath>
</text>
</svg>
javascript复制const revealPath = document.getElementById("revealPath");
const spot = document.getElementById("spot");
const totalLength = revealPath.getTotalLength();
let progress = 0;
function moveSpot() {
progress += 0.003;
if (progress >= 1) progress = 0;
const point = revealPath.getPointAtLength(progress * totalLength);
spot.setAttribute("cx", point.x);
spot.setAttribute("cy", point.y);
requestAnimationFrame(moveSpot);
}
moveSpot();
这里面核心是 SVG 的 getPointAtLength 方法,它能根据路径长度比例拿到某个点的坐标。我把圆形遮罩的圆心放在路径上,让它在每一帧跟着 moveSpot 更新,视觉上就像一束光在文字上扫过,被照到的文字才显示出来。
不过 mask 里的圆形是白色,遮罩区域外部会被隐藏,所以如果你希望文字始终隐约可见、只有圆形区域是高亮,可以再加一个半透明或者更低透明度的文字层叠在底下。这个技巧在实际页面中很出效果,但也有代价:SVG 元素和 mask 组合在部分浏览器里会有锯齿或触发 GPU 层合成问题,所以不要在整个页面大量使用,通常控制在几个标题以内。
4.4 片段八:WAAPI 配合 offset-path 做多段文字进场
最后这段比较适合开场视频感的效果:页面加载后,两三句关键词依次从不同方向沿着各自的路径进入画面。实现上我会用 CSS 给每个文字元素设置 offset-path,再用 Web Animations API 控制每段的 offset-distance。
CSS 部分先定义几条不同的路径:
css复制.word-one {
offset-path: path("M 0 0 C 120 80, 260 80, 380 0");
}
.word-two {
offset-path: path("M 320 0 C 200 -90, 80 -40, 0 0");
}
JavaScript 部分按顺序播放:
javascript复制const introWords = document.querySelectorAll(".intro-word");
introWords.forEach((word, index) => {
const distance = word.animate(
[{ offsetDistance: "100%" }, { offsetDistance: "0%" }],
{
duration: 900,
delay: index * 400,
easing: "cubic-bezier(0.22, 0.61, 0.36, 1)",
fill: "both",
}
);
});
注意我在 animate 里先让 offsetDistance 从 100% 开始,到 0% 结束,这会让文字从路径终点倒着飞回起点。如果你希望从起点出发,则反一下,把 0% 放在第一帧。fill: both 能保证动画开始前和结束后元素都保持在正确的位置,不会出现一闪而过的失控状态。
WAAPI 的好处是不需要引入额外第三方库,原生支持 cancel、play、pause 等方法,可以很方便地和用户交互绑定。我实际使用时会在整个文案区域进入视口之后再触发这段动画,避免用户滚动到中段时文字已经表演完了。触发判断用 IntersectionObserver 最方便。
5. 落地封装:我常用的集成顺序和工程化技巧
5.1 HTML 结构里的内容优先级
代码演示可以零散写,但进了真实项目就必须考虑内容语义。我的习惯是让“未增强的原始文本”始终存在于 HTML 中。
用 SVG textPath 时,文字在 SVG 里,要把可读内容通过 aria-label 补上。用 JS 拆字时,原始 div 里先写入完整文案,标签再通过 aria-label 暴露给读屏。我会尽量避免把可见文字都交给 JavaScript 动态拼出来,因为那会造成首屏无文本、SEO 抓不到、读屏混乱三个问题一起出现。
一个相对安全的模板是这样:页面结构里保留 <h1>,哪怕它后期会被 JS 替换成多个 span,也保证在没有 JS 的环境下 <h1> 的文字可以被正常浏览。
5.2 CSS 变量统一控制进度
当同一个页面里有多个“路径”效果时,我最常犯
