CSS & JS Effect – 画直角三角形与边框

前言

续上一篇《画箭头 arrow》后,我们继续来画一点有趣的东西 -- 带边框的直角三角形(right triangle + border)。

 

需求 & 难点

最终要实现的设计长这样

image

难点就是 message box 的 caret,那一个带边框的 right triangle。

三角形的基本画法

画三角形,很多年前我写过一篇《画三角形 Triangle

可以用 border 来实现,也可以用 clip-path 来实现。

HTML

<div class="triangle"></div>

Styles

/* 用 border 实现 right triangle */
.triangle {
  border-left: 150px solid red;
  border-top: 100px solid transparent;
  width: 0;
}

/* 用 clip-path 实现 right triangle */
.triangle {
  width: 150px;
  height: 100px;
  background-color: red;
  clip-path: polygon(0 0, 100% 100%, 0 100%);
}

效果都是一样的(clip-path 比较直观,推荐使用)

image

三角形 + 边框?

上面提到的需求

image

白色是三角形,绿色是它的边框。

如果我们用 border 来画三角形,那 border 已经用掉了,不可能再多一层 border,所以 border 方案肯定实现不了这个。

clip-path 方案的话,我们直接天真的加上 border 看看效果

.triangle {
  border-left: 16px solid blue; /* 直接添加 border */
  border-top: 16px solid blue;  /* 直接添加 border */
  width: 150px;
  height: 100px;
  background-color: red;
  clip-path: polygon(0 0, 100% 100%, 0 100%);
}

效果

image

border-left 没问题,但三角形的斜线(hypotenuse)border 没画出来。

这是因为 CSS border 本来就不支持斜线。

双层 div 重叠实现三角形 + 边框?

两个传统方案都不行,立马能想到的偏方是用两个 div,一个负责画三角形,一个负责画边框。

所谓的画边框,其实是画一个更大的三角形,然后大小三角形重叠,这样背后(大三角)被前面(小三角)遮挡了中心,露出来的地方就成了小三角的边框。

我们直接画一个来看看效果:

HTML

<div class="frame">
  <div class="border"></div>
  <div class="triangle"></div>
</div>

需要定位,所以搞了一个 frame,里面有两个 div,一个负责画三角形,一个负责画边框。

Styles

.frame {
  --border-width: 24px;
  --width: 256px;
  --height: 128px;

  .triangle {
    width: var(--width);
    height: var(--height);
    background-color: pink;
    clip-path: polygon(0 0, 100% 100%, 0 100%);
  }
}

先画一个三角形

效果

image

接着我们画 border(大三角形)

.frame {
  position: relative;

  .border {
    position: absolute;
    left: 0;
    bottom: 0;
    width: calc(var(--border-width) + var(--width));
    height: var(--height);
    background-color: blue;
    clip-path: polygon(0 0, 100% 100%, 0 100%);
    opacity: 0.5;
  }
}

首先是定位 postion absolute,因为要重叠两个 div。

再来是 width 比较大,增加了一个 border-width 宽度。

效果

image

粉色是小三角形,蓝色是大三角形。

位置不正确,我们需要把小三角往右移,让它形成 border-left。

.frame .triangle {
  transform: translateX(var(--border-width));
}

效果

image

现在,难点来了!

这个大三角的 width height 要放多少呢?

我们试试看

.frame .border {
  width: calc(var(--border-width) + var(--width));
  height: calc(var(--border-width) + var(--height));
}

width height 都增加一个 border-width。

image

不对,继续试试

width: calc((var(--border-width) * 2) + var(--width));

两个 border-width 呢?

image

有点像了,但还是偏薄,继续增加看看。

width: calc((var(--border-width) * 3) + var(--width));
height: calc((var(--border-width) * 2) + var(--height));

image

接近了,但上半段偏大,下半段偏小,有一点点微差。

没错,你看出来了!这样乱试,只能做出一个不太精准的边框。

大三角的 width height 和 clip-path 的点,都需要精心计算,不可以单纯的用 width + 几个 border-width。

😨...难道是要搞 tan sin cos hypot radius radian degree pi xy 那一套来计算?不会吧...

 

双层 div 重叠实现三角形 + 边框

要算就来算呗!

几何图

使用在线工具 geometry

image

里面是小三角形,由三个点构成(InCenter、InTop、InRight)

外面是 border,由三个点构成(OutCenter、OutTop、OutRight)

看懂它 border 画法吗?

image

红线是 border width,从小三角往外。

那我们要算什么呢?

想象一下...

image

整个红框代表一个 div,里面的六个点就是 clip-path polygon 的六个点,OutRight 的 x 坐标就是整个 div 的 width,OutTop 的 y 坐标就是整个 div 的 height。

clip-path 出来的是小三角的 border。

有了这个 border,我们就可以实现两个 div 重叠,一个负责小三角形,一个负责 border。

计算几何

我们最终需要 width height 和 polygon 六个点的坐标。

开始算 🚀

image

我们先把一眼就看出来的东西给填上。

InTop 暂时作为整个几何图的中心点 x: 0, y: 0

小三角形的高度是 2(代表两个格子),长度是 4 个格子,border width 是一个格子。

立马可以看出来的坐标有 OutCenter、InCenter、InRight,其它的都需要一些计算。

好,一个一个算

image

小三角里 InTop 是多少度呢?

算 degree 的 formula 是啥?不熟悉的读者可以看这篇《前端基础几何计算》。

使用 right triangle 公式:tan(θ) = opposite / adjacent

opposite 对边指的是 InCenter → InRight 的长度,也就是 4。

adjacent 邻边指的是 InTop → InCenter 的长度,也就是 2。

算式:

tan(θ) = 4 / 2

θ = atan(4 / 2)

θ = 63.434948822922010648427806279547...°(是个无理数,我们拿前面 4 位小数就好)

CSS 写法

--in-top-degree: atan(4 / 2) = 63.4349°

好,目前已知的几个角度有:

image

InCenter 90°,InTop 63.4349°,∠ InTop → InRight → OutRight 也是 90°

right triangle 的规定:

  1. 内有三个角度 InCenter、InTop、InRight

  2. 中间 InCenter 一定是 90°

  3. 三个总和一定是 180°

依照这些规定,我们有 InCenter、InTop 就可以计算出 InRight 了。

--in-right-degree: calc(180deg - 90deg - var(--in-top-degree)) 也就是 180° - 90° - 63.4349° = 26.5651°

好,又多了几个算好的角度:

image

接下来,关键的来了 -- 算 OutRight 的坐标。

image

InRight → OutRight 这条线的长度就是 border width,也就是一个格子。

图中下方的三个角度(左到右):

第一个是上面我们已算出的 InRight 26.5651°

第二个是 InTop → InRight → OutRight,它一定是 90°

第三个则是 180° - 26.5651° - 90° = 63.4349°,它刚巧和 InTop 是相同的角度。

image

也不是巧合啦,因为 InTop → InRight → OutRight 是 90° 嘛。

有了这些就可以计算出 OutRight 的坐标了,公式是:

x = cos(θ) * radius

y = sin(θ) * radius

θ 指的是 InRight 外围角度,也就是刚巧和 InTop 一样的 63.4349°

radius 是 InRight → OutRight 长度,也就是 border width 一个格子。

--out-right-x: calc(cos(63.4349deg) * 1px) 结果是 0.4375px

--out-right-y: calc(sin(63.4349deg) * 1px) 结果是 0.890625px

注意哦,我们这个算法是以 InRight 作为中心点

image

把它换成以 InTop 为中心,公式是

fromPoint + (fromBasePoint - toBasePoint)

fromPoint 就是当下的 OutRight 0.4375, 0.890625

fromBasePoint 是 InRight 原本的坐标 4, -2(以 InTop 为中心)

toBasePoint 就是 InTop 0, 0

OutRight x = 0.4375 + (4 - 0) = 4.4375

OutRight y = 0.890625 + (-2 - 0) = -1.109375

image

好,剩下最后一个 OutTop 坐标,算完就能收工了!

要算出 OutTop 坐标,首先要算出 OutTop → OutRight 这条斜线的长度。

image

用这个公式

radius = x / cos(θ)

以 OutRight 为中心点,θ 是 63.4349° + 90° = 153.4349°

x 则是图中的红线长度,原本 OutCenter x 的坐标是 -1,以 InTop 为中心,我们把它换成以 OutRight 为中心。

x = -1 + (0 - 4.4375) = -5.4375

所以是 --out-top-to-out-right-length : calc(-5.4375px / cos(153.4349deg)),得出 6.07812px

image

最后计算 OutTop y 的公式是

y = sin(θ) * radius

同样以 OutRight 为中心点,θ 是 63.4349° + 90° = 153.4349°

radius 就是刚刚算出来的 OutTop → OutRight 6.07812

所以是 --out-top-y : calc(sin(153.4349deg) * 6.0781),得出 2.70312

目前 OutTop 的坐标 -5.4375, 2.70312 是以 OutRight 为中心点。

我们改成以 InTop 为中心点:

公式是

fromPoint + (fromBasePoint - toBasePoint)

OutTop x = -5.4375 + (4.4375 - 0) = -1

OutTop y = 2.70312 + (-1.09375 - 0) = 1.60937

至此,6 个坐标全部算出来了。

image

OutTop -1, 1.60937、OutRight 4.4375, -1.09375、InRight 4, -2、InTop 0, 0、InCenter 0, -2、OutCenter -1, -2

这些坐标并不能直接用于 clip-path 的 polygon,因为它们的中心点是 InTop,而 polygon 是以 OutTop 为中心点。

还有 polygon 坐标 y 往下是 position(和 translateY 一样),可几何图 y 往下是 negative。

首先换中心点,公式:

fromPoint + (fromBasePoint - toBasePoint)

换完后再把所有的坐标 y 拿来乘于 -1 做反向就是 polygon 坐标 y 了。

转换后的结果:

OutTop 0, 0、OutRight 5.4375, 2.70312、InRight 5, 3.60937、InTop 1, 1.160937、InCenter 1, 3.60937、OutCenter 0, 3.60937

代码测试

HTML

<div class="frame">
  <div class="border"></div>
  <div class="triangle"></div>
</div>

Styles

.frame {
  --border-width: 24px;
  --triangle-width: 256px;
  --triangle-height: 128px;

  .border {
    @function change-base-point($currPoint, $currBasePoint, $nextBasePoint) {
      @return calc(#{$currPoint} + (#{$currBasePoint} - #{$nextBasePoint}));
    }

    --in-top-x: 0px;
    --in-top-y: 0px;
    --in-center-x: 0px;
    --in-center-y: calc(-1 * var(--triangle-height));
    --out-center-x: calc(-1 * var(--border-width));
    --out-center-y: calc(-1 * var(--triangle-height));
    --in-right-x: var(--triangle-width);
    --in-right-y: calc(-1 * var(--triangle-height));
    --in-top-degree: atan2(var(--triangle-width), var(--triangle-height));
    --out-right-x-based-on-in-right: calc(cos(var(--in-top-degree)) * var(--border-width));
    --out-right-y-based-on-in-right: calc(sin(var(--in-top-degree)) * var(--border-width));
    --out-right-x: #{change-base-point(var(--out-right-x-based-on-in-right), var(--in-right-x), var(--in-top-x))};
    --out-right-y: #{change-base-point(var(--out-right-y-based-on-in-right), var(--in-right-y), var(--in-top-y))};
    --out-top-x-based-on-out-right: #{change-base-point(var(--out-center-x), var(--in-top-x), var(--out-right-x))};
    --out-top-to-out-right-length: calc(var(--out-top-x-based-on-out-right) / cos(var(--in-top-degree) + 90deg));
    --out-top-y-based-on-out-right: calc(sin(var(--in-top-degree) + 90deg) * var(--out-top-to-out-right-length));
    --out-top-x: #{change-base-point(var(--out-top-x-based-on-out-right), var(--out-right-x), var(--in-top-x))};
    --out-top-y: #{change-base-point(var(--out-top-y-based-on-out-right), var(--out-right-y), var(--in-top-y))};
    --polygon-out-top-x: 0px;
    --polygon-out-top-y: 0px;
    --polygon-out-center-x: #{change-base-point(var(--out-center-x), var(--in-top-x), var(--out-top-x))};
    --polygon-out-center-y: calc(#{change-base-point(var(--out-center-y), var(--in-top-y), var(--out-top-y))} * -1);
    --polygon-in-center-x: #{change-base-point(var(--in-center-x), var(--in-top-x), var(--out-top-x))};
    --polygon-in-center-y: calc(#{change-base-point(var(--in-center-y), var(--in-top-y), var(--out-top-y))} * -1);
    --polygon-in-top-x: #{change-base-point(var(--in-top-x), var(--in-top-x), var(--out-top-x))};
    --polygon-in-top-y: calc(#{change-base-point(var(--in-top-y), var(--in-top-y), var(--out-top-y))} * -1);
    --polygon-in-right-x: #{change-base-point(var(--in-right-x), var(--in-top-x), var(--out-top-x))};
    --polygon-in-right-y: calc(#{change-base-point(var(--in-right-y), var(--in-top-y), var(--out-top-y))} * -1);
    --polygon-out-right-x: #{change-base-point(var(--out-right-x), var(--in-top-x), var(--out-top-x))};
    --polygon-out-right-y: calc(#{change-base-point(var(--out-right-y), var(--in-top-y), var(--out-top-y))} * -1);

    width: var(--polygon-out-right-x);
    height: var(--polygon-out-center-y);
    background-color: blue;

    clip-path: polygon(
      var(--polygon-out-top-x) var(--polygon-out-top-y),
      var(--polygon-out-right-x) var(--polygon-out-right-y),
      var(--polygon-in-right-x) var(--polygon-in-right-y),
      var(--polygon-in-top-x) var(--polygon-in-top-y),
      var(--polygon-in-center-x) var(--polygon-in-center-y),
      var(--polygon-out-center-x) var(--polygon-out-center-y)
    );
  }

  position: relative;

  .triangle {
    position: absolute;
    bottom: 0;
    left: var(--border-width);
    width: var(--triangle-width);
    height: var(--triangle-height);
    background-color: pink;
    clip-path: polygon(0 0, 100% 100%, 0 100%);
  }
}

效果

image

右下角如果想画完整的尖角也不难,用算 OutTop 的方式就可以算出来了。

补上最后一个缺角几何计算

我们这里快速算一下就好,毕竟本篇的题目没有这个需求。

image

我们要计算出 OutRight2 的 x 坐标。

图中有三个角度依规则它们是一样的,我们可以加以利用。

先算 OutRight → OutRight2 这条线的长度。

采用公式:

radius = y / sin(θ)

y 指的是 OutRight2 的 y 但中心点要换成 OutRight,θ 则是图中最右边的角度 -26.5651°(因为它是往下,所以用 negative)。

--out-right-to-out-right-2-length: calc(-0.90625 / sin(-26.5651deg)) 结果是 2.0264331510784896

接下来采用公式

x = cos(θ) x radius

--out-right-2-x: calc(cos(-26.5651deg) * 2.0264331510784896) 结果是 1.8124961388344796

最后把中心点换成 InTop,OutRight2 的坐标 y 是 4.4375 + 1.8124961388344796 = 6.249996138834479

补上相关代码

--out-right-2-y: calc(-1 * var(--triangle-height));
--out-right-to-out-right-2-length: calc(
  #{change-base-point(var(--out-right-2-y), var(--in-top-y), var(--out-right-y))} /
    sin((90deg - var(--in-top-degree)) * -1)
);
--out-right-2-x-based-on-out-right: calc(
  cos((90deg - var(--in-top-degree)) * -1) * var(--out-right-to-out-right-2-length)
);
--out-right-2-x: #{change-base-point(var(--out-right-2-x-based-on-out-right), var(--out-right-x), var(--in-top-x))};

--polygon-out-right-2-x: #{change-base-point(var(--out-right-2-x), var(--in-top-x), var(--out-top-x))};
--polygon-out-right-2-y: calc(#{change-base-point(var(--out-right-2-y), var(--in-top-y), var(--out-top-y))} * -1);

width: var(--polygon-out-right-2-x); /* 改成 out-right-2 */

clip-path: polygon(
  var(--polygon-out-top-x) var(--polygon-out-top-y),
  var(--polygon-out-right-2-x) var(--polygon-out-right-2-y), /* 改成 out-right-2 */
  var(--polygon-in-right-x) var(--polygon-in-right-y),
  var(--polygon-in-top-x) var(--polygon-in-top-y),
  var(--polygon-in-center-x) var(--polygon-in-center-y),
  var(--polygon-out-center-x) var(--polygon-out-center-y)
);

效果

image

打完收工!

 

clip-path 偏差问题!

本来以为做好了

image

仔细一看,咦...

image

怎么会有一条线?

debug 一圈后发现,原来是 clip-path 遇到小数点造成的。

单元测试一下

<body style="padding-top: 100px; padding-left: 100px">
  <div style="width: 100px; height: 100px; clip-path: polygon(0 0, 100% 100%, 20px 100%, 20px 80%, 10px 80%, 10px 100%, 0 100%); background-color: red;"></div>
</body>

这是一个三角形,左下角开了一个孔。

image

一切正常。

<body style="padding-top: 100px; padding-left: 100px">

把 body padding 改成有小数点

效果

image

看到吗?边边的 1px 颜色变了。

image

这条线就是这样来的。

只要被 clip 的 div 它的位置是在小数上(因为各种排版关系),就有可能中招。

怎么破?

如果 clip-path 的形状比较简单,那可以尝试 clip 多 1px。

如果无法靠 1px 去调整,那还真的就没招了😔。

要解决 clip-path 的问题,最直接的方式就是不要用 clip-path!

只要你不用它,就不会遇到它的问题了丫!

可,不用它如何实现 clip 效果呢?

答案是 svg

<svg width="100" height="100" viewBox="0 0 100 100">
  <polygon
    points="0 0 100 100 20 100 20 80 10 80 10 100 0 100"
    fill="red"
  />
</svg>

效果

image

完美,没有 1px 变色问题。

用 SVG 实现三角形 + 边框?

对 svg 一窍不通的可以先看这篇《用 SVG 画曲线和箭头》。

想用 svg 取代 clip-path 并不容易,除非搭配 JS。

因为,像本篇的需求,clip-path polygon 的各个坐标是透过 CSS variables + calc + tan sin cos 这一堆 function 计算出来的。

而 svg polygon points 属性,不支持写 CSS variables、calc、tan sin cos 等等。

因此,只有两条路:

  1.  用 JS 计算出 polygon 坐标,然后 set 进 svg。

  2.  提前计算好 polygon 坐标,hardcode 写入 svg,只透过 svg viewBox 等比例缩放去兼容 responsive(未必可以兼容所有场景需求)。

我们粗略试一下就好

<svg
  width="500"
  viewBox="0 0 290.72 166.83"
  xmlns="http://www.w3.org/2000/svg"
>
  <!-- border -->
  <polygon
    points="0,0 290.733,145.367 280,166.833 24,38.8328 24,166.833 0,166.833"
    fill="blue"
  />

  <!-- triangle -->
  <polygon
    points="24,166.833 24,38.8328 280,166.833"
    fill="pink"
  />
</svg>

关键是那些数字,哪来的?

它们来自计算好的 CSS。

我们设定好 CSS 变量

--border-width: 24px;
--triangle-width: 256px;
--triangle-height: 128px;

然后查看计算后的各个 polygon 坐标

image

接着把它们 copy paste 到 svg 就可以了。

svg 方案有两个重大缺陷:

第一个是它不灵活。

svg + viewBox 可以等比例缩放,但不像 clip-path 方案那样灵活到可以分开调 width height border-width。

svg 搭配 CSS variables 和 calc 有非常多的限制(要灵活,只能靠 JS)。

第二个是它的计算方式没有那么符合直觉。

上面我们算几何,是从小三角往外添加 border(因为小三角的角度是确定了的,因此它很容易算)。

svg 的直觉应该是先定一个 width height,然后往内算出其它的 polygon。

比如说

image

先有红框的 width height 和 border-width。

接着推算出 InTop、InRight、OutRight 的坐标。

我尝试了一下,非常困难,困难到要解一元四次方,要用牛顿法...我看还是算了吧,别用 svg 了。

 

 

 

 

 

 

 

 

 

todo

草稿,之后可以洗掉了

用 SVG 实现三角形 + 边框?

要自己计算几何也太费劲了。

既然 CSS 不给力,那就试试 svg 吧,不是有一句说:Why not just use SVG?

注:我 SVG 的功力连入门教程都没有学过...

使用工具 Inkscape

image

一个 path element

属性 d 是绘画 command,有点像 canvas

第一条 command 是 m 100,100

意思是 move to 坐标 100, 100

第二条 command v -25 

意思是画 vertical 线条,从当前位置 -25,也就是从 y 100 画线到 y 75。(这条线就是三角形高度那一条线)

第三条 command l 50, 25 

意思是画 line,从当前 100, 75 画到 100+50, 75+25 也就是 150, 100。(这条线就是三角形的斜线)

最后一个 command z

意思是画闭环,连回去 100, 100。(这条线就是三角形的长度那条线)

有了三角形形状,直接添加 stroke style 就可以了。

效果

image

SVG 的强项就是不管你画什么形状,它都可以直接加上 stroke(a.k.a border),CSS 完全办不到!

BUT !

它有一个大问题 -- 它的边框是左右两侧平均分配的。

这跟我们习惯的 CSS 不同。

CSS border 是里面,outline 是外面,没有两侧平均的(需要用黑科技弄)。

除此之外,还有一些问题

image

想象一下... 我画了两条红线,然后添加 stroke 绿色边框。

先不说它平均分配的问题,即便它像 outline 往外画边框,可整个 SVG 的 width height 是多少?

我要如何操控变量,比如调整小三角的 width height 或者 border-width。

SVG 的玩法视乎是先画好一个固定的图,width height 全部算好好,然后你只能 scale 去调动它。

嗯... 我思来想去(可能我 svg 功力太新手),SVG 好像不太合适这个场景。

 

posted @ 2025-12-17 01:48  兴杰  阅读(0)  评论(0)    收藏  举报