CSS & JS Effect – 用 SVG 画曲线和箭头

HTML + CSS 画箭头的局限

上上一篇《画箭头》介绍了如何用纯 HTML + CSS 手法来画箭头,它可以做出这类设计:

Snipaste_2025-12-19_00-16-36

但是!

遇到这种设计需求,它就不管用了。

image

这款箭头最大的特色是它那弯曲的箭身。

术语叫 -- 二次贝塞尔曲线(Quadratic Bézier Curve)

gif

CSS 完全不支持这个东东,我们只能靠 SVG 或 Canvas 来实现。

 

SVG 基础

SVG 是一种特殊的 XML 格式,我们可以通过 elements、attributes 来描述点、线、面、颜色等等。

它和 HTML + Styles 有异曲同工之妙,但更像是在作画,而不是布局。

new SVG

注:一般制作 SVG 会透过 Inkscape 等绘画工具,但我们是学基础,而且只是想画曲线和箭头(太简单),因此不需要劳师动众,直接写代码就可以了。

<svg width="500" height="500" style="outline: 4px solid black;"></svg>

一个 svg element,设置 width height,它就是一个画布(outline 是为了让我们看得见它)。

注:svg 无法像 div 那样依据内容决定高度宽度,它的玩法是先确定画布大小,然后按坐标作画,最后外部可以缩放整个 svg。

效果

image

我们什么都还没画,因此画布是一片空白。

画矩形 rectangle

<svg width="300" height="300" style="outline: 4px solid black;">
  <rect width="80" height="100" fill="pink" x="50" y="100" />
</svg>

<rect> element 表示画一个 rectangle 矩形。

width height 属性定义矩形的宽度和高度。

fill 相当于 CSS 的 background-color,也就是整个矩形的颜色。

x, y 则是它的坐标。

用 CSS 理解的话:<svg> 是 position: relative,<rect> 是 position: ablsolute

x, y 相当于 top 和 left。

效果

image

画横线 horizontal line

除了 <rect> 以外,还有许多画形状的 elements,比如:<circle> 圆形、<ellipse> 椭圆、<polygon> 多边形等等。

这些就不一一介绍了。

我们聚焦在 <path> 这个 element 就好。

它是最抽象的,什么形状都画的出来。

先试试画一条简单的横线:

<svg width="300" height="300" style="outline: 4px solid black;">
  <path d="
    m 50 50
    h 100
  " stroke-width="10" stroke="red" />
</svg>

d 属性是绘画命令(有点类型 Canvas)

m 50 50 意思是把画笔移动到 x:50, y:50 坐标,但它只是移动画笔,还没下笔画哦。

下一个命令 h 100 意思是画一条横线(horizontal line),从当前的 50,50 坐标往右画 100px,这就是横线的长度。

仅靠 d 属性还无法让横线呈现出来,因为它只画了长度,并没有画高度。

接着添加 stroke-width 属性,它负责横线的高度。

我们可以先把它看作是 CSS 的 border-width(当然,有很多不同之处,这个我们接下来慢慢讲)。

stroke 属性则是 border-color -- 边框颜色。

好,总结一下全部绘画命令:

  1. 移动画笔到 50,50 坐标

  2. 往右画 100px 横线(横线有了长度)

  3. 给这条横线添加 border(横向有了高度)

  4. 再给这个 border 上色

效果

image

stroke 是左右各一半

仔细看我们会发现

image

这段白色区域只有 45px,可我们的第一道命令明明是移动到 50,50 坐标,怎么少了 5px 呢?

原因是 SVG stroke 的逻辑和 CSS border 不同。

CSS 的 border 是往内,outline 是往外;而 SVG 的 stroke 则是一半一半。

想象一下,一开始画笔移动到 y: 50 这个位置,这时白色区域有 50px。

接着画横线,这没影响,然后画 stroke 10px,这个 stroke 不是从第 51px 开始往下画 10px。

而是往下画 5px(51 到 55),往上画 5px(50 到 46)。

因此,我们最终看到的结果是 1 到 45 是白色区域,46 到 55 才是红色的线。

image

另外,也因为这个原因,stroke 最好设置成双数,否则一半一半就会出现小数:stroke 1px 会被渲染成 2px 但是颜色会从红色变成浅红色。

画直线

横线的命令是 h(stand for horizontal),直线则是 v(stand for vertial)

m 50 50
v 100

效果

image

同样的,换成 x 是 45px,因为 stroke 是一半一半的缘故。

画斜线

<path d="
  m 50 50
  l 100 80
" stroke-width="10" stroke="red" />

斜线的命令是 l(stand for line)

我们需要提供两个参数,或者可以理解为提供一个点坐标。

l 100 80 的意思是从当前的 50,50 往右 100px,往下 80px,把这个点和 50,50 这个点连起来成为一条斜线。

image

l 100 80 是 "相对" 写法,换成 "绝对" 写法是 L 150 130 L 变成大写,坐标本来是相对于 50,50 变成绝对 0,0。

stroke の 样式

stroke 可以做一些基本样式,比如圆角、dash 等等:

stroke-linecap

<path d="
  m 50 50
  h 100
" stroke-width="10" stroke="red" stroke-linecap="butt" />

stroke-linecap 可以填三种值:butt、round、square。

默认是 butt,也就是我们上面看到的样子。

round 长这样:

image

左右两边变圆润了,同时整条线也边长了。

左边多了 5px,右边也多了 5px。

这 5px 便是 stroke-width 10px 的一半。

stroke-linecap: square 长这样:

image

它也是变长了,但没有圆润,依然是方形。

stroke-dasharray

stroke-dasharray 用来让线条变成 dash dash dash(相当于 CSS 的 border-style: dashed

<svg width="300" height="300" style="outline: 4px solid black;">
  <path d="
    m 50 50
    h 200
  " stroke-width="10" stroke="red" />
  
  <path d="
    m 50 70
    h 200
  " stroke-width="10" stroke="red" stroke-dasharray="20, 5" />
</svg>

stroke-dasharray="20, 5" 20 是指 dash 的长度,5 是指 dash 的间隔。

image

画折叠线

<svg width="300" height="300" style="outline: 4px solid black;">
  <path d="
    m 10 10
    l 100 50
  " stroke-width="10" stroke="red" />

  <path d="
    m 10 110
    l 100 -50
  " stroke-width="10" stroke="blue" />
</svg>

这里画了两条斜线,长这样

image

它的特色是

image

两条线交会在同一个点上。

假如我们用一条折叠线来画,它会变成这样:

<svg width="300" height="300" style="outline: 4px solid black;">
  <path d="
    m 10 10
    l 100 50
    l -100 50
  " stroke-width="10" stroke="red" />
</svg>

l 100 50 之后再接一道命令 l -100 50,它就会继续画下去。

效果

image

绿线是我自己加上的,它代表我们画线的左边,红色则是 stroke。

黑色的区域是它默认的 fill。

虽然我们只花了两条线,但它已经可以形成一个 "面" 了。

<path fill="none" />

给 <path> 加上属性 fill="none" 把黑色去掉,它就变成了一个箭头

image

stroke-linejoin

折叠线的交会处要如何画,是由 linejoin 负责。

默认是 stroke-linejoin="miter" 也就是把它画成尖角。

我们对比上一个两条线就一清二楚了

image

绿线是我加上去的,看出尖角了吗?

stroke-linejoin="round" 是画圆角

image

它的原理是这样

image

stroke-linejoin="bevel" 是平角
imageimage

画二次贝塞尔曲线

gif

关键就是需要提供一个拉力点(或者叫控制点 control point),图中的 P1。

<path d="
  m 10 50
  q 25 -50 100 0
" stroke-width="5" stroke="red" fill="none" />

q stand for Quadratic Bézier curve

25 -50 是拉力点的坐标

100 0 则是线结尾的坐标

效果

image

拉力点大概是在绿点那个位置,依照弧度把直线拉弯。

好,SVG 基础会被使用到的,大概就是这些了。接下来,我们准备开始作画吧 🚀。

缩放 – viewBox

<svg width="100" height="100" style="outline: 4px solid black;">
  <rect x="10" y="10" width="50" height="50" fill="red" />
</svg>

 这是一个 svg 里面有一个 rectangle,它长这样

image

如果我们把 width height 放大一倍,变成 200,它会变成这样

 image

除了整个 svg 框变大了之外,里面的 rectangle 原封不动。

我们加上 viewBox 属性

<svg width="200" height="200" style="outline: 4px solid black;" viewBox="0 0 100 100">
  <rect x="10" y="10" width="50" height="50" fill="red" />
</svg>

 viewBox 定义 width 是 100,可 svg 是 200,这意味着放大了一倍。

所有内容的定位和尺寸都要长一倍,等同于我们自己写

 <rect x="20" y="20" width="100" height="100" fill="red" />

效果

image

另外,viewBox 前面两个参数是坐标 x, y,它和缩放没有直接关系,它的用途是限制展现区域。

比如说,viewBox="10 10 100 100",出来的效果是

image

rectangle 的 x, y 偏移没了,因为被 viewBox clip 掉了。

 

用 SVG 画箭头

上一 part 我们在介绍折叠线时,粗糙的画了一个箭头做例子。

但,箭头其实有许多细节,这一 part 我们来补上。

用 rect 画箭头

在《画箭头》这篇中,我用了两个 div + transform 的方式来实现箭头。

SVG 也可以采用相同方案,用两个 rectangle + transform(对,SVG 支持 transform)来实现。

<svg width="375" height="250" style="outline: 4px solid black;">
  <rect x="125" y="45" width="250" height="80" fill="green" opacity="0.5"
    rx="40"
    transform="translate(14.641, 65.359) rotate(30, 375, 45)"
  />

  <rect x="0" y="95" width="375" height="60" fill="red" rx="30" opacity="0.5" />

  <rect x="125" y="125" width="250" height="80" fill="blue" opacity="0.5"
    rx="40"
    transform="translate(14.641, -65.359) rotate(-30, 375, 205)"
  />
</svg>

效果

image

虽然是画出来了,但它比 div 版复杂多了。

需要计算 svg width height、rect 的定位 x y、又不能写 variable、calc,总之非常别扭。

这些数具体怎样算我就不讲解了,总之在 svg 就用 svg way,不要试图模仿 div way。

用折叠线画箭头

我们只画箭头就好,箭身就不画了。

image

红框是整个 svg,红线是 svg 线条。

首先起笔坐标是 40, 40。因为箭头的厚度是 80,中心就是 40, 40。

image

接着往右下角画斜线,长度是 250,角度是 30°。

image

这时我们就需要算坐标 x, y 了。

首先,调整一下斜线的长度。

上面我们说是 250,但由于需要做 stroke-linecap="round",这会导致线变长(原理上面讲解过了),因此需要扣掉变长的 80,最后线长是 170。

接着想象一个 right triangle

image

hypot 是 170,角度是 30°,求 adjacent(也就是 x 坐标) 和 opposite(也就是 y 坐标)。

算式 

x = cos(30°) * 170 = 147.224...

y = sin(30°) * 170 = 84.999...

接下来是画回去

image

角度一样,斜线长度一样,无需再算,用回 147.244...84.999... 就可以了。

<path d="
  m 40 40
  l 147 85
  l -147 85
" stroke-width="80" stroke="red" fill="none" stroke-linecap="round" stroke-linejoin="round" />

起笔 m 40 40

斜线画到相对坐标 147, 85(我进位了)

继续画斜线到相对坐标 -147 85(往左画,所以是 native)

最后,我们算 svg 的 width height。

image

40 是箭头厚度 80 的一半

147, 85 是斜线坐标。

全部加一加得出:width 227,height 250。

最终代码

<svg width="227" height="250" style="outline: 4px solid black;">
  <path d="
    m 40 40
    l 147 85
    l -147 85
  " stroke-width="80" stroke="red" fill="none" stroke-linecap="round" stroke-linejoin="round" />
</svg>

效果

 image

是不是觉得挺简单的?

 

SVG marker 基础

回到我们的设计需求

image

它有一条线,这条线是 dash 样式,有二次贝塞尔曲线弯曲。

它有一个箭头,

画线、画箭头,我们都掌握了。

可,线(箭身)和箭头的关系如何绑定,这部分我们还没有学到。

箭头会在线(箭身)的结尾,并且箭头的方向还会配合箭身的曲线调整,让它始终指向一个正确的方向。

这就是箭头与箭身的逻辑关系。

SVG 有一个叫 marker 的东东,专门用来处理这层关系。

<svg width="300" height="300" style="outline: 4px solid black;">
  <defs>
    <marker id="arrow" markerWidth="2" markerHeight="2">
      <rect width="2" height="2" fill="blue" />
    </marker>
  </defs>
</svg>

 <defs> stand for definition,在 <defs> 里的 elements 不会被直接绘画出来。

我们可以把 <marker> 当作是一个局部的 svg 区块 -- 画布里的小画布。

它里面有一个 <rect> 矩形 。

接着我们画一条线,并且把 marker 放到这条线的结尾上。

<path d="
  m 10 10
  h 100
" stroke-width="10" stroke="red" marker-end="url(#arrow)" />

 透过 marker-end="url(#arrow)" 让线和 marker 产生关联。

效果

image

注:绿线是我们实际画的线条,红色是它的 stroke。

marker 尺寸

先看 <marker> 和 <rect> 的尺寸。

<marker id="arrow" markerWidth="2" markerHeight="2">

<rect width="2" height="2" fill="blue" />

写是写 2,但最终绘画出来却是 20px。

这是因为,默认情况下,<marker> 有自带一个属性 markerUnits="strokeWidth"

它的意思是,<marker> 里 1 unit 等于它的关联者(线条)的 stroke width。

线条 stroke width 是 10px,<rect width="2" 代表 2 x 10px = 20px,因此最终 rectangle 的宽度就变成了 20px。

如果我们不希望它关联 stroke width,可以设置 markerUnits="userSpaceOnUse",这样 <rect width="2"> 就是单纯的 2px。

marker 定位

image

marker 的定位规则是这样:marker 的左上角,会对着线(绿线)的结尾。

如上图,线 y 是 10,marker y 也是 10,第 11px 开始是 marker 的内容。

线 x 是 10,线长是 100,marker x 是 10 + 100 = 110,第 111px 开始是 marker 的内容。

注:marker 的定位不受 stroke 影响,它对的是绿线,比如 linecap 会导致红线变长,但这不影响 marker。

透过 refX,refY 属性可以修改 marker 的位置。

<marker id="arrow" markerWidth="2" markerHeight="2" refY="1">

image

refY="1" 不是 translateY(10px) 哦。

它是改变了 marker 的定位点,从本来的左上角(0, 0)变成了(0,1)。

因此,最后的效果是 marker 往上移了 10px。

context-stroke

除了尺寸、定位,颜色也可以关联 <rect fill="context-stroke" /> 这样 <rect> 的颜色就会和它的关联者(线条)一样。

image

 

SVG marker + 箭头

上一 part 为了聚焦 <marker> 我刻意采用了较为简单的 <rect>,这里我们把它换成正式场景需要的箭头。

这里我用 JavaScript 生成箭头 <marker>,这样大家比较容易看得懂它的计算方式

const thickness = 1;          // 和线的 stroke 一样厚
const length = thickness * 4; // 随便一个比例,有个长方形就可以了
const rotate = 30;

// 起笔坐标
const start = { x: thickness / 2, y: thickness / 2  }; 

// 画斜线的相对坐标
const move = { 
  x: Math.cos(degreeToRadian(rotate)) * (length - thickness),
  y : Math.sin(degreeToRadian(rotate)) * (length - thickness)
}

// 箭头 svg 的尺寸
const width = (thickness / 2) + move.x + (thickness / 2);
const height = (thickness / 2) + move.y + move.y + (thickness / 2);


console.log(`
  <marker id="arrow" markerWidth="${ width }" markerHeight="${ height }">
    <path d="
      m ${ start.x } ${ start.y }
      l ${ move.x } ${ move.y }
      l -${ move.x } ${ move.y }
    " stroke-width="1" stroke="red" fill="none" stroke-linecap="round" stroke-linejoin="round" />
  </marker>
`);

function degreeToRadian(degree: number): number {
  return degree * Math.PI / 180;
}

出来的 <marker> 长这样

<marker id="arrow" markerWidth="3.598076211353316" markerHeight="3.9999999999999996">
  <path d="
    m 0.5 0.5
    l 2.598076211353316 1.4999999999999998
    l -2.598076211353316 1.4999999999999998
  " stroke-width="1" stroke="red" fill="none" stroke-linecap="round" stroke-linejoin="round" />
</marker>

效果

 image

位置不对,因为默认对接位置是 marker 的左上角对线的结尾,我们需要调 refX 和 refY。

箭头要居中,因此 refY 应是 markerHeight 的一半。

<marker id="arrow" markerWidth="${ width }" markerHeight="${ height }" refY="${ height / 2 }">

<marker id="arrow" markerWidth="3.598076211353316" markerHeight="3.9999999999999996" refY="1.9999999999999998">

效果

image

我们要确保整个 svg 有足够的空间,线的位置也要正确。

marker 的高度是 4,线 stroke 是 10,乘起来就是 40,这就是 svg 要有的高度

<svg width="300" height="40" style="outline: 4px solid black;">

 线在中心,因此线的起笔 y 坐标应该要是 20 -- m 10 20

效果

image

接着是算 refX。

如果我们把它的对接点设在最尾巴 -- refX = markerWidth,会长这样

image

如果我们往前一点点,那箭头就会超出箭身。

总之它们息息相关,我们在调的时候要思考整体。

一个比较日常会用的设置是这样:

给线条加上 stroke-linecap="round",此时箭身会增加长度左右 5px(因为 stroke 是 10px)。

接着  refX = markerWidth - arrow-thickness / 2

最终效果

image

箭头最前方凸出的圆角刚好和线凸出的圆角相合。

最终 JS 代码 

const thickness = 1; // 和线的 stroke 一样厚
const length = thickness * 4; // 随便一个比例,有个长方形就可以了
const rotate = 30;

// 起笔坐标
const start = { x: thickness / 2, y: thickness / 2  }; 

// 画斜线的相对坐标
const move = { 
  x: Math.cos(degreeToRadian(rotate)) * (length - thickness),
  y : Math.sin(degreeToRadian(rotate)) * (length - thickness)
}

// 箭头 svg 的尺寸
const width = (thickness / 2) + move.x + (thickness / 2);
const height = (thickness / 2) + move.y + move.y + (thickness / 2);


console.log(`
  <marker id="arrow" markerWidth="${ width }" markerHeight="${ height }" refY="${ height / 2 }" refX="${ width - thickness / 2 }">
    <path d="
      m ${ start.x } ${ start.y }
      l ${ move.x } ${ move.y }
      l -${ move.x } ${ move.y }
    " stroke-width="1" stroke="red" fill="none" stroke-linecap="round" stroke-linejoin="round" />
  </marker>
`);
View Code

最终 HTML 代码

<!-- 
  width = line-length + stroke-width(因为 stroke-linecap)
  height = markerHeight * stroke-width
-->
<svg width="110" height="40" style="outline: 4px solid black;">
  <defs>
    <marker id="arrow" markerWidth="3.598076211353316" markerHeight="3.9999999999999996" refY="1.9999999999999998" refX="3.098076211353316">
      <path d="
        m 0.5 0.5
        l 2.598076211353316 1.4999999999999998
        l -2.598076211353316 1.4999999999999998
      " stroke-width="1" stroke="red" fill="none" stroke-linecap="round" stroke-linejoin="round" />
    </marker>
  </defs>

  <!-- m (stroke-width / 2) (markerHeight * stroke-width / 2) -->
  <path d="
    m 5 20
    h 100
  " stroke-width="10" stroke="red" stroke-linecap="round" marker-end="url(#arrow)" />
</svg>
View Code

二次贝塞尔曲线

我们把线条弯曲,看看它对箭头有何影响。

<path d="
  m 80 80
  q 25 -100 200 0
" stroke-width="10" stroke="red" stroke-linecap="round" marker-end="url(#arrow)" fill="none" />

效果

image

完全没有影响,箭头原封不动。但,这样不行啊,超丑的。

解决方式很简单,给 <marker> 加上属性 orient="auto" 就可以了,它会依据线条的弧度自行调整。

image

 

总结

本篇讲解了如何使用 svg 画曲线和箭头。

要实现这个设计

image

我们需要使用 JavaScript,获取特定点对点的坐标(用 getBoundingClientRect),然后计算动态生成 svg 即可。

 

posted @ 2025-12-19 00:10  兴杰  阅读(5)  评论(0)    收藏  举报