我之前偶然发现了一些 这样的 动画,这给了我一个想法,要尽可能少用代码,不使用外部库,使用各种方法创建自己的版本,其中一些方法利用了我们现在可以使用的一些更现代的功能,例如 CSS 变量。本文将指导您完成构建这些演示的过程。
在其他任何事情之前,这是我们试图实现的动画

无论我们选择哪种方法来重新创建上述动画,我们总是从静态阴阳形状开始,该形状看起来如下所示
此起始形状的结构由以下插图描述
首先,我们有一个直径为 d
的大圆圈。在这个圆圈的内部,我们紧紧地嵌入了两个较小的圆圈,每个圆圈的直径是我们初始大圆圈直径的一半。这意味着这两个较小圆圈的直径等于大圆圈的半径 r
(或 .5*d
)。在这两个直径为 r
的圆圈的内部,我们有一个更小的同心圆。如果我们要绘制穿过所有这些圆圈的中心点的直径——上面插图中的线段 AB,它与内部圆圈的交点将其分成 6
个相等的小段。这意味着其中一个最小圆圈的直径为 r/3
(或 d/6
),其半径为 r/6
。
知道了所有这些,让我们开始第一个方法!
普通 HTML + CSS
在这种情况下,我们可以用一个元素及其两个伪元素来完成它。构建符号的原理由以下动画说明(由于整个事物都会旋转,因此我们切换轴线无关紧要)
查看 thebabydino (@thebabydino) 在 CodePen 上的 Pen。
实际元素是大圆圈,它有一个从上到下的渐变,在中间有一个急剧的过渡。伪元素是我们放置在其上的较小圆圈。其中一个较小圆圈的直径是大圆圈直径的一半。两个较小圆圈都与大圆圈垂直居中对齐。
所以让我们开始编写可以实现此目标的代码!
首先,我们为大圆圈确定一个直径 $d
。我们使用视窗单位,以便在调整大小后一切都能很好地缩放。我们将此直径值设置为其 width
和 height
,我们使用 border-radius
使元素圆形,并通过在中间从 black
到 white
的急剧过渡来提供从上到下的渐变 background
。
$d: 80vmin;
.☯ {
width: $d; height: $d;
border-radius: 50%;
background: linear-gradient(black 50%, white 0);
}
到目前为止,一切都很好
查看 thebabydino (@thebabydino) 在 CodePen 上的 Pen。
现在让我们继续使用伪元素创建的较小圆圈。我们让我们的元素 display: flex
,并通过设置 align-items: center
使其子元素(在本例中为伪元素)与它垂直居中对齐。我们让这些伪元素具有其父元素的一半 height
(50%
),并确保在水平方向上,它们分别覆盖大圆圈的一半。最后,我们使用 border-radius
使它们圆形,并提供一个虚拟 background
,并设置 content
属性,以便我们可以看到它们
.☯ {
display: flex;
align-items: center;
/* same styles as before */
&:before, &:after {
flex: 1;
height: 50%;
border-radius: 50%;
background: #f90;
content: '';
}
}
查看 thebabydino (@thebabydino) 在 CodePen 上的 Pen。
接下来,我们需要为它们提供不同的背景
.☯ {
/* same styles as before */
&:before, &:after {
/* same styles as before */
background: black;
}
&:after { background: white }
}
现在我们正在取得进展!
查看 thebabydino (@thebabydino) 在 CodePen 上的 Pen。
在我们获得静态符号之前,剩下的就是为这两个伪元素提供边框。black
应该有一个 white
边框,而 white
应该有一个 black
边框。这些边框应该是伪元素直径的三分之一,这是大圆圈直径的一半的三分之一——这给了我们 $d/6
。
.☯ {
/* same styles as before */
&:before, &:after {
/* same styles as before */
border: solid $d/6 white;
}
&:after {
/* same styles as before */
border-color: black;
}
}
但是,结果看起来不太正确
查看 thebabydino (@thebabydino) 在 CodePen 上的 Pen。
这是因为,在垂直方向上,border
会累加到 height
上,而不是从 height
中减去。在水平方向上,我们没有设置 width
,因此它会从可用空间中减去。这里有两个可能的修复方法。一种是在伪元素上设置 box-sizing: border-box
。第二种是将伪元素的 height
更改为 $d/6
——我们将选择这种方法
查看 thebabydino (@thebabydino) 在 CodePen 上的 Pen。
我们现在有了基本形状,所以让我们继续动画!此动画涉及从第一个伪元素缩小到其原始大小的一半(这意味着缩放因子 $f
为 .5
)的状态,而第二个伪元素扩展以占据所有剩余可用空间——意味着大圆圈的直径(是其原始大小的两倍)减去第一个圆圈的直径(是其原始大小的 $f
)的状态,到第二个伪元素缩小到其原始大小的 $f
,而第一个伪元素扩展到其原始大小的 2 - $f
的状态。第一个伪元素圆圈相对于其最左侧点进行缩放(因此我们需要设置 transform-origin
为 0 50%
),而第二个伪元素相对于其最右侧点进行缩放(100% 50%
)。
$f: .5;
$t: 1s;
.☯ {
/* same styles as before */
&:before, &:after {
/* same styles as before */
transform-origin: 0 50%;
transform: scale($f);
animation: s $t ease-in-out infinite alternate;
}
&:after {
/* same styles as before */
transform-origin: 100% 50%;
animation-delay: -$t;
}
}
@keyframes s { to { transform: scale(2 - $f) } }
我们现在有了我们一直在追求的形状变化动画
查看 thebabydino (@thebabydino) 在 CodePen 上的 Pen。
最后一步是让整个符号旋转
$t: 1s;
.☯ {
/* same styles as before */
animation: r 2*$t linear infinite;
}
@keyframes r { to { transform: rotate(1turn) } }
我们得到了 最终结果!
但是,我们还可以做一件事来使编译后的 CSS 更有效率:使用 CSS 变量消除冗余!
white
可以用 HSL 格式编写为 hsl(0, 0%, 100%)
。色调和饱和度无关紧要,任何亮度为 100%
的值都是 white
,因此我们只需将它们都设置为 0
以简化操作。类似地,black
可以编写为 hsl(0, 0%, 0%)
。色调和饱和度同样无关紧要,任何亮度为 0%
的值都是 black
。鉴于此,我们的代码变为
.☯ {
/* same styles as before */
&:before, &:after {
/* same styles as before */
border: solid $d/6 hsl(0, 0%, 100% /* = 1*100% = (1 - 0)*100% */);
transform-origin: 0 /* = 0*100% */ 50%;
background: hsl(0, 0%, 0% /* 0*100% */);
animation: s $t ease-in-out infinite alternate;
animation-delay: 0 /* = 0*-$t */;
}
&:after {
/* same styles as before */
border-color: hsl(0, 0%, 0% /* = 0*100% = (1 - 1)*100% */);
transform-origin: 100% /* = 1*100% */ 50%;
background: hsl(0, 0%, 100% /* = 1*100% */);
animation-delay: -$t /* = 1*-$t */;
}
}
从上面可以得出
- 我们
transform-origin
的x
分量对于第一个伪元素是calc(0*100%)
,对于第二个伪元素是calc(1*100%)
- 我们的
border-color
对于第一个伪元素是hsl(0, 0%, calc((1 - 0)*100%))
,对于第二个伪元素是hsl(0, 0%, calc((1 - 1)*100%))
- 我们的
background
对于第一个伪元素是hsl(0, 0%, calc(0*100%))
,对于第二个伪元素是hsl(0, 0%, calc(1*100%))
- 我们的
animation-delay
对于第一个伪元素是calc(0*#{-$t})
,对于第二个伪元素是calc(1*#{-$t})
这意味着我们可以使用一个充当开关的自定义属性,它对于第一个伪元素是 0
,对于第二个伪元素是 1
.☯ {
/* same styles as before */
&:before, &:after {
/* same styles as before */
--i: 0;
border: solid $d/6 hsl(0, 0%, calc((1 - var(--i))*100%));
transform-origin: calc(var(--i)*100%) 50%;
background: hsl(0, 0%, calc(var(--i)*100%));
animation: s $t ease-in-out calc(var(--i)*#{-$t}) infinite alternate;
}
&:after { --i: 1 }
}
这消除了两次写入所有这些规则的必要性:我们现在所要做的就是翻转开关!不幸的是,这 目前只在 WebKit 浏览器中有效,因为 Firefox 和 Edge 不支持使用 calc()
作为 animation-delay
值,而且 Firefox 也不支持在 hsl()
中使用它。
更新:Firefox 57+ 支持使用 calc()
作为 animation-delay
值,Firefox 59+ 也支持在 hsl()
中使用它。
画布 + JavaScript
虽然有些人可能认为这种方法太过复杂,但我真的很喜欢它,因为它需要的代码量与 CSS 相同,而且它具有良好的支持和性能。
我们从一个 canvas
元素和一些基本样式开始,这些样式只是为了将其置于其容器(在本例中为 body
元素)的中间并使其可见。我们还使用 border-radius
使其呈圆形,以便在 canvas
上绘制时简化操作。
$d: 80vmin;
body {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background: lightslategray;
}
canvas {
width: $d; height: $d;
border-radius: 50%;
background: white;
}
到目前为止,一切都很好——我们有一个白色圆盘
查看 thebabydino (@thebabydino) 在 CodePen 上的 Pen。
好的,现在让我们继续进行 JavaScript 部分!在其他任何事情之前,我们需要获取 canvas
元素、2D 上下文并设置 canvas
元素的 width
和 height
属性(否则我们绘制在 canvas
上的内容会拉伸)。然后,我们需要有一个大圆圈的半径。我们让此半径等于 canvas
元素计算大小的一半,并在完成此操作后,我们平移上下文,以便将 canvas
的 0,0
点置于正中间(它最初位于左上角)。我们确保在每次调整大小后重新计算半径和 width
和 height
属性,因为在 CSS 中,我们使 canvas
尺寸取决于视窗。
const _C = document.querySelector('canvas'),
CT = _C.getContext('2d');
let r;
function size() {
_C.width = _C.height = Math.round(_C.getBoundingClientRect().width);
r = .5*_C.width;
CT.translate(r, r);
};
size();
addEventListener('resize', size, false);
完成此操作后,我们可以继续在画布上绘制。绘制什么?好吧,一个由三个弧组成的形状,如下面的插图所示
为了在 2D 画布上绘制弧线,我们需要了解一些信息。首先,是该弧线所属圆形的中心点的坐标。然后,我们需要知道这个圆形的半径以及弧线起点和终点相对于圆形局部坐标系中 x
轴的角度。最后,我们需要知道是从起点到终点顺时针还是逆时针(如果我们没有指定,默认是顺时针)。
第一个弧线位于大圆上,大圆的直径等于画布尺寸,并且由于我们将 canvas
的 0,0
点放置在这个圆形的正中间,这意味着我们知道第一组坐标(它是 0,0
)和圆形半径(它是 r
)。这个弧线的起点是这个圆形的左端点 - 这个点在 -180°
(或 -π
)处。终点是圆形的右端点,位于 0°
(在弧度制中也是 0
)。如果你需要回顾圆形上的角度,可以查看这个辅助演示。
这意味着我们可以创建一个路径并向其中添加这个弧线,并且为了查看我们到目前为止所做的,我们可以关闭这个路径(在本例中,这意味着用直线将弧线的终点连接到起点)并填充它(使用默认填充色,即 黑色
)
CT.beginPath();
CT.arc(0, 0, r, -Math.PI, 0);
CT.closePath();
CT.fill();
结果可以在以下 Pen 中看到
查看 thebabydino (@thebabydino) 在 CodePen 上创建的 Pen。
现在让我们继续第二个弧线。它所属圆形的中心点的坐标是 .5*r,0
,其半径为 .5*r
(大圆半径的一半)。它从 0
到 π
,以顺时针方向移动。因此,我们在关闭路径之前添加到路径中的弧线是
CT.arc(.5*r, 0, .5*r, 0, Math.PI);
添加这个弧线后,我们的形状变为
查看 thebabydino (@thebabydino) 在 CodePen 上创建的 Pen。
现在我们还剩下一个弧线要添加。半径与上一个弧线相同(.5*r
),第一组坐标是 -.5*r,0
。这个弧线从 0
到 -π
,它是第一个不是顺时针方向的弧线,所以我们需要更改那个标志
CT.arc(-.5*r, 0, .5*r, 0, -Math.PI, true);
现在我们有了我们想要的形状
查看 thebabydino (@thebabydino) 在 CodePen 上创建的 Pen。
接下来,我们将在这个路径中添加黑色圆形。我们不会创建另一个路径,因为目的是将具有相同填充色的所有形状分组到同一个路径中,以提高性能。调用 fill()
很昂贵,因此我们不想比真正需要时更频繁地调用它。
圆形只是从 0°
到 360°
(或从 0
到 2*π
)的弧线。这个圆形的中心点与我们绘制的最后一个弧线(-.5*r, 0
)的中心点重合,其半径是前两个弧线半径的三分之一。
CT.arc(-.5*r, 0, .5*r/3, 0, 2*Math.PI);
现在我们离完成整个符号越来越近了
查看 thebabydino (@thebabydino) 在 CodePen 上创建的 Pen。
剩下的就是创建一个 白色
圆形,它关于 y
轴对称于 黑色
圆形。这意味着我们需要切换到 白色
填充色,开始一个新的路径,然后向其中添加一个弧线,使用与我们添加 黑色
圆形形状时几乎相同的命令 - 唯一的区别是我们反转了 x
坐标的符号(这次是 +
,而不是 -
)。之后,我们关闭该路径并填充它。
CT.fillStyle = 'white';
CT.beginPath();
CT.arc(.5*r, 0, .5*r/3, 0, 2*Math.PI);
CT.closePath();
CT.fill();
现在我们有了静态的符号!
查看 thebabydino (@thebabydino) 在 CodePen 上创建的 Pen。
对于动画,我们希望从较小的弧线之一缩小到其原始半径的一半(所以我们使用 F
为 .5
的缩放因子)的状态,到另一个弧线相应扩展的状态。
在初始状态下,鉴于较小弧线的半径最初为 .5*r
,则第一个弧线在按因子 F
缩小后的半径为 r1 = F*.5*r
。由于较小圆形的半径需要加起来等于大圆的半径 r
,我们得出第二个较小圆形的半径为 r2 = r - r1 = r - F*.5*r
。
为了获得第一个较小弧线在初始状态下的原点的 x
坐标,我们需要从它开始的点的 x
坐标中减去它的半径。这样,我们得出这个坐标是 r - r1 = r2
。类似地,为了获得第二个较小弧线原点的 x
坐标,我们需要将它的半径加到它结束的点的坐标上。这样,我们得出这个坐标是 -r + r2 = -(r - r2) = -r1
。
对于最终状态,两个半径的值是反转的。第二个是 F*.5*r
,而第一个是 r - F*.5*r
。
在我们的动画的每一帧中,我们将第一个较小弧线的当前半径从最小值(F*.5*r
)增加到最大值(r - F*.5*r
),然后我们开始将其减小到最小值,然后循环重复,同时相应地缩放另一个较小弧线的半径。
为了做到这一点,我们首先在 size()
函数中设置最小和最大半径
const F = .5;
let rmin, rmax;
function size() {
/* same as before */
rmin = F*.5*r;
rmax = r - rmin;
};
在任何时间点,第一个较小弧线的当前半径是 k*rmin + (1 - k)*rmax
,其中这个 k
因子不断从 1
变为 0
,然后又回到 1
。这听起来类似于 [0, 360°]
区间上的余弦函数。在 0°
处,余弦函数的值为 1
。然后它开始下降,并一直下降,直到它到达 180°
,此时它达到最小值 -1
,之后该函数的值开始再次上升,直到它到达 360°
,此时它再次为 1
查看 thebabydino (@thebabydino) 在 CodePen 上创建的 Pen。
好吧,但是余弦函数的值在 [-1, 1]
区间内,我们需要一个函数提供 [0, 1]
区间内的值。那么,如果我们向余弦函数中添加 1
,那么我们将整个图形向上移动,现在值位于 [0, 2]
区间内
查看 thebabydino (@thebabydino) 在 CodePen 上创建的 Pen。
[0, 2]
不是 [0, 1]
,所以我们还需要做的是将整个东西除以 2
(或乘以 .5
,是一样的)。这将把我们的图形压缩到所需的区间。
查看 thebabydino (@thebabydino) 在 CodePen 上创建的 Pen。
很好,但是那个角度是怎么回事?我们没有从 0°
到 360°
的角度。如果我们要使用 requestAnimationFrame
,我们只有当前帧的数字,它从 0
开始,然后不断增加。那么,在开始时,我们为一个动画周期设置了总帧数 T
(第一个弧线从最小半径值到最大半径值,然后又回来)。
对于每一帧,我们计算当前帧数 (t
) 与总帧数的比率。在一个周期中,这个比率从 0
到 1
。如果我们将这个比率乘以 2*Math.PI
(与 360°
相同),那么结果在整个周期中从 0
到 2*Math.PI
。所以这将是我们的角度。
const T = 120;
(function ani(t = 0) {
let k = .5*(1 + Math.cos(t/T*2*Math.PI)),
cr1 = k*rmin + (1 - k)*rmax, cr2 = r - cr1;
})();
下一步是在这个函数中放入实际绘制符号的代码。开始、关闭、填充路径、更改填充色的代码保持不变,创建大弧线所需的代码也保持不变。发生变化的是
- 较小弧线的半径 - 它们分别是
cr1
和cr2
- 较小弧线的中心点的
x
坐标 - 它们分别位于cr2
和-cr1
处 黑色
和白色
圆形的半径 - 它们分别是cr2/3
和cr1/3
- 这些圆形的中心点的
x
坐标 - 它们分别位于-cr1
和cr2
处
所以我们的动画函数变为
const T = 120;
(function ani(t = 0) {
let k = .5*(1 + Math.cos(t/T*2*Math.PI)),
cr1 = k*rmin + (1 - k)*rmax, cr2 = r - cr1;
CT.beginPath();
CT.arc(0, 0, r, -Math.PI, 0);
CT.arc(cr2, 0, cr1, 0, Math.PI);
CT.arc(-cr1, 0, cr2, 0, -Math.PI, true);
CT.arc(-cr1, 0, cr2/3, 0, 2*Math.PI);
CT.closePath();
CT.fill();
CT.fillStyle = 'white';
CT.beginPath();
CT.arc(cr2, 0, cr1/3, 0, 2*Math.PI);
CT.closePath();
CT.fill();
})();
这给了我们动画的初始状态
查看 thebabydino (@thebabydino) 在 CodePen 上创建的 Pen。
在我们真正开始动画化弧线的半径之前,我们还需要处理几件事。首先,如果我们现在就开始动画,我们只是会在之前帧绘制的内容之上绘制每一帧的形状,这将造成一片混乱。为了避免这种情况,我们需要在每一帧绘制任何内容之前清除画布。我们清除的是可见的部分,它位于画布尺寸矩形内部,该矩形的左上角位于 -r,-r
处
CT.clearRect(-r, -r, _C.width, _C.width);
我们需要解决的第二个小问题是,我们切换到 白色
填充色,但是在下一帧的开始,我们需要 黑色
填充色。因此,我们需要在每一帧的第一个路径开始之前进行此切换
CT.fillStyle = 'black';
现在我们可以真正开始动画了
requestAnimationFrame(ani.bind(this, ++t));
这给了我们变形动画,但我们仍然需要旋转整个东西。在解决这个问题之前,让我们再次看看 k
的公式
let k = .5*(1 + Math.cos(t/T*2*Math.PI))
T
和 2*Math.PI
在整个动画过程中都是常数,因此我们可以将该部分提取出来并将其存储为一个常数角度 A
const T = 120, A = 2*Math.PI/T;
(function ani(t = 0) {
let k = .5*(1 + Math.cos(t*A));
/* same as before */
})();
现在对于每一帧,我们也可以在清除画布后将上下文旋转 A
度。
CT.rotate(A);
这种旋转在每一帧中不断累加,现在我们有了我们一直想要的旋转和变形动画。
SVG + JavaScript
我们从一个 SVG 元素开始,并使用与 canvas
案例中几乎相同的 CSS。
$d: 80vmin;
body {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background: lightslategray;
}
svg {
width: $d; height: $d;
border-radius: 50%;
background: white;
}
这将给我们一个白色的圆盘。
查看 thebabydino (@thebabydino) 在 CodePen 上的 Pen。
不太令人兴奋,所以让我们继续在 SVG 画布上绘制一些东西。就像在 canvas
案例中一样,我们将绘制一条由相同三个弧线(一个半径等于 SVG viewBox
大小一半的大弧线,以及两个半径等于静态情况下大弧线半径一半的小弧线)和两个小圆圈(半径等于它们共享中心点的较小弧线的 1/3)组成的路径。
所以我们首先选择一个半径 r
值,并用它设置 svg
元素的 viewBox
。
- var r = 1500;
svg(viewBox=[-r, -r, 2*r, 2*r].join(' '))
下一步是添加由三个弧线组成的 path
。在 SVG 中创建 path
与 canvas
有点不同。在这里,形状由路径数据 d
属性描述,在我们的例子中,它由以下部分组成:
- 一个“移动到”(
M
)命令,之后我们指定路径起点(在本例中也是大弧线起点)的坐标。 - 每个弧线之后都有一个“弧线到”(
A
)命令,之后我们描述我们的弧线;每个弧线都从上一个弧线的终点开始,或者在第一个弧线的情况下,从路径的起点开始。
让我们仔细看看“弧线到”(A
)命令的组成部分。
- 我们弧线沿其坐标系
x
轴的半径 - 在大弧线的情况下等于r
,在两个较小弧线的情况下等于.5*r
。 - 我们弧线沿其坐标系
y
轴的半径 - 在圆弧的情况下等于沿x
轴的半径,就像我们这里的情况一样(只有在椭圆弧的情况下才不同,但这超出了本文的范围)。 - 我们弧线坐标系的旋转 - 这只影响椭圆弧的情况下弧线的形状,所以对于圆弧来说,我们可以安全地始终将其设为
0
以简化操作。 - 大弧线标志 - 如果我们的弧线大于半圆,则为
1
,否则为0
;由于我们的弧线正好是半圆,它们不超过半圆,因此在本例中它始终为0
。 - 扫掠标志 - 如果弧线从起点到终点顺时针方向移动,则为
1
,否则为0
;在我们的例子中,前两个弧线是顺时针方向的,而第三个不是,所以我们对三个弧线使用的值分别是1
、1
和0
。 - 弧线终点的
x
坐标 - 这是我们需要为每个弧线确定的值。 - 弧线终点的
y
坐标 - 也是我们需要为每个弧线确定的值。
在这一点上,我们已经知道我们需要的大部分内容。我们仍然需要弄清楚的是弧线终点的坐标。所以让我们考虑下面的图示。
从上面的图示我们可以看到,第一个弧线(大弧线)从 (-r,0)
开始,到 (r,0)
结束,第二个弧线到 0,0
结束,第三个弧线到 (-r,0)
结束(也是我们路径的起点)。请注意,即使较小弧线的半径发生变化,所有这些点的 y
坐标都保持 0
,但第二个弧线终点的 x
坐标只在本例中恰好为 0
,当较小弧线的半径正好是大弧线的一半时。在一般情况下,它是 r - 2*r1
,其中 r1
是第二个弧线(第一个较小弧线)的半径。这意味着我们现在可以创建我们的路径。
- var r1 = .5*r, r2 = r - r1;
path(d=`M${-r} 0
A${r} ${r} 0 0 1 ${r} 0
A${r1} ${r1} 0 0 1 ${r - 2*r1} 0
A${r2} ${r2} 0 0 0 ${-r} 0`)
这将给我们我们一直在追求的三个弧线形状。
查看 thebabydino (@thebabydino) 在 CodePen 上的 Pen。
现在让我们继续处理小圆圈。我们已经从 canvas
方法中知道它们中心点的坐标和半径。
circle(r=r1/3 cx=r2)
circle(r=r2/3 cx=-r1)
默认情况下,所有这些形状都填充了 黑色
,因此我们需要在 (r2,0)
处的圆圈上显式设置 白色
填充。
circle:nth-child(2) { fill: white }
现在我们有了静态形状!
查看 thebabydino (@thebabydino) 在 CodePen 上的 Pen。
接下来,我们将使用 JavaScript 来动画化路径形状以及两个小圆圈的大小和位置。这意味着我们首先要获取这些元素,获取大圆圈的半径 R
,并设置一个缩放因子 F
,它可以使我们得到弧线可以缩放到的最小半径(RMIN
)。我们还设置了总帧数(T
)和单位角度(A
)。
const _P = document.querySelector('path'),
_C = document.querySelectorAll('circle'),
_SVG = document.querySelector('svg'),
R = -1*_SVG.getAttribute('viewBox').split(' ')[0],
F = .25, RMIN = F*R, RMAX = R - RMIN,
T = 120, A = 2*Math.PI/T;
动画函数与 canvas
案例中的几乎相同。唯一不同的是,现在为了改变路径形状,我们需要改变它的 d
属性,而为了改变小圆圈的半径和位置,我们需要改变它们的 r
和 cx
属性。但其他一切的工作方式都完全相同。
(function ani(t = 0) {
let k = .5*(1 + Math.cos(t*A)),
cr1 = k*RMIN + (1 - k)*RMAX, cr2 = R - cr1;
_P.setAttribute('d', `M${-R} 0
A${R} ${R} 0 0 1 ${R} 0
A${cr1} ${cr1} 0 0 1 ${R - 2*cr1} 0
A${cr2} ${cr2} 0 0 0 ${-R} 0`);
_C[0].setAttribute('r', cr1/3);
_C[0].setAttribute('cx', cr2);
_C[1].setAttribute('r', cr2/3);
_C[1].setAttribute('cx', -cr1);
requestAnimationFrame(ani.bind(this, ++t));
})();
这将给我们变形后的形状。
查看 thebabydino (@thebabydino) 在 CodePen 上的 Pen。
只有一件事需要处理,那就是整个符号的旋转,我们将其设置在 _SVG
元素上。
let ca = t*A;
_SVG.style.transform = `rotate(${+ca.toFixed(2)}rad)`;
现在我们得到了想要的结果 使用 SVG 和 JavaScript 也可以得到!
SVG + CSS
还有一种方法可以做到这一点,尽管它涉及从 CSS 中更改路径数据等内容,而这只是 Blink 浏览器目前支持的内容(而且它们 甚至不符合最新的规范)。
它也很容易出错,因为我们需要在 SVG viewBox
属性和 Sass 变量中设置相同的半径值。
- var r = 1500;
svg(viewBox=[-r, -r, 2*r, 2*r].join(' '))
path
circle
circle
$d: 65vmin;
$r: 1500;
$r1: .5*$r;
$r2: $r - $r1;
$rmin: .25*$r;
$rmax: $r - $rmax;
我们可以从 CSS 中访问此半径的值,但只能作为自定义属性,如果我们要做类似的事情。
- var r = 1500;
svg(viewBox=[-r, -r, 2*r, 2*r].join(' '))
style :root { --r: #{r} }
但是,虽然这在某些情况下可能非常有用,但在这里是无用的,因为我们目前无法将 CSS 变量放入我们用 Sass 构建的路径数据字符串中。所以我们不得不将相同的值同时设置在 viewBox
属性和 Sass 代码中。
基本样式是相同的,我们可以用 Sass 创建路径数据,这与 Pug 方法类似。
$r: 1500;
$r1: .5*$r;
$r2: $r - $r1;
path {
$data: 'M#{-$r} 0' +
'A#{$r} #{$r} 0 0 1 #{$r} 0' +
'A#{$r1} #{$r1} 0 0 1 #{$r - 2*$r1} 0' +
'A#{$r2} #{$r2} 0 0 0 #{-$r} 0';
d: path($data);
}
这将给我们三个弧线形状。

对于两个小圆圈,我们设置它们的半径和沿 x 轴的位置。我们还需要确保其中一个为白色。
circle {
r: $r1/3;
cx: $r2;
&:nth-child(2) { fill: white }
&:nth-child(3) {
r: $r2/3;
cx: -$r1
}
}
现在我们有了静态形状。

为了获得我们想要的效果,我们需要以下动画。
- 一个
path
形状的变形animation
,其中较小弧线的第一个的半径从最小可能的半径($rmin: .25*$r
)到最大可能的半径($rmax: $r - $rmin
),然后反过来,而最后一个弧线的半径从$rmax
到$rmin
,然后反过来;这可以通过一个从一个极端到另一个极端的关键帧animation
来完成,然后使用alternate
值作为animation-direction
。 - 另一个交替的
animation
,它将第一个小圆圈的半径从$rmin/3
扩展到$rmax/3
,然后再次缩小到$rmin/3
;第二个小圆圈使用相同的animation
,只是延迟了正常animation-duration
的值。 - 第三个交替的动画,它使两个小圆圈的中心点来回移动;在第一个(
白色
)小圆圈的情况下,它从$rmax
移动到$rmin
;在第二个(黑色
)圆圈的情况下,它从-$rmin
移动到-$rmax
;我们可以在这里做的事情是使用 CSS 变量作为开关(它只在 WebKit 浏览器中有效,但是从 CSS 设置路径数据或圆圈半径或偏移量也没有更好的支持)。
所以让我们先看看变形 @keyframes
。这些是通过设置与之前几乎相同的路径数据创建的,只是将 $r1
替换为 $rmin
,将 $r2
替换为 $rmax
来作为 0%
的关键帧,并将它们反过来作为 100%
的关键帧。
@keyframes m {
0% {
$data: 'M#{-$r} 0' +
'A#{$r} #{$r} 0 0 1 #{$r} 0' +
'A#{$rmin} #{$rmin} 0 0 1 #{$r - 2*$rmin} 0' +
'A#{$rmax} #{$rmax} 0 0 0 #{-$r} 0';
d: path($data);
}
100% {
$data: 'M#{-$r} 0' +
'A#{$r} #{$r} 0 0 1 #{$r} 0' +
'A#{$rmax} #{$rmax} 0 0 1 #{$r - 2*$rmax} 0' +
'A#{$rmin} #{$rmin} 0 0 0 #{-$r} 0';
d: path($data);
}
}
现在我们只需要在 path
元素上设置这个 animation
。
$t: 1s;
path { animation: m $t ease-in-out infinite alternate }
形状变形部分有效!

下一步是继续对两个小圆圈进行缩放和移动。缩放 @keyframes
遵循与变形相同的模式。半径值为 0%
时为 $rmin/3
,100%
时为 $rmax/3
。
@keyframes s {
0% { r: $rmin/3 }
100% { r: $rmax/3 }
}
我们在 circle
元素上设置了这个 animation
。
circle { animation: s $t ease-in-out infinite alternate }
现在两个小圆圈的半径正在动画化。

这是一个开始,但我们这里有一些问题。首先,第二个小 circle
应该在第一个变大的时候缩小,反之亦然。我们可以通过设置一个 animation-delay
来解决这个问题,该延迟取决于我们最初设置为 0
的 CSS 变量,然后在第二个小圆圈上切换到 1
。
circle {
--i: 0;
animation: s $t ease-in-out calc(var(--i)*#{$t}) infinite alternate
&:nth-child(3) { --i: 1 }
}
如前所述,使用 calc()
作为 animation-delay
值只能在 WebKit 浏览器中使用,但从 CSS 设置 r
的支持更差,因此 animation-delay
不是我们这里遇到的最大问题。结果可以在下面看到。

这已经好多了,但我们仍然需要沿x
轴动画小圆圈的位置。我们使用一组@keyframes
来实现这一点,这些@keyframes
使cx
从$rmax
到$rmin
再返回第一个小circle
,并从-$rmin
到-$rmax
再返回第二个小circle
。在这两种情况下,我们都有不同的顺序和不同的符号,因此我们需要想出一个满足两者的关键帧动画。
解决顺序问题很容易——我们使用与缩放半径animation
相同的animation-delay
。
但符号怎么办呢?好吧,我们再次使用自定义属性--i
。对于第一个小circle
,它是0
,对于第二个小circle
,它是1
,所以我们需要一个函数,该函数接收--i
,当该变量的值为0
时,它返回1
,当该变量的值为1
时,它返回-1
。想到的最简单的函数是将-1
的幂次方为--i
。不幸的是,CSS 不支持这样做——我们只能在calc()
中进行算术运算。但是,calc(1 - 2*var(--i))
是另一个可行的解决方案,而且它并不比这个复杂多少。使用这个,我们的代码变成
circle {
--i: 0;
--j: calc(1 - 2*var(--i));
animation: s $t ease-in-out calc(var(--i)*#{-$t}) infinite alternate;
animation-name: s, x;
&:nth-child(3) { --i: 1 }
}
@keyframes x {
0% { cx: calc(var(--j)*#{$rmax}) }
100% { cx: calc(var(--j)*#{$rmin}) }
}
结果可以在下面看到……它并不像预期的那样

我们看到的是,在两个结束值之间出现了一个突然的在50%
处的翻转,而不是一个平滑的animation
。这不是我们想要的,所以看来我们需要放弃这种策略。
不过我们还有一个选择:将cx
与transform
结合起来。两个小圆圈始终以使它们中心点之间的距离为$r
的方式定位。所以,我们可以将第二个小圆圈定位在-$r
处,然后将它们都平移一个在$rmax
和$rmin
之间的距离
circle {
transform: translate($r2*1px);
animation: s $t ease-in-out infinite alternate;
animation-name: s, x;
&:nth-child(3) {
cx: -$r;
animation-delay: -$t, 0s
}
}
@keyframes x {
0% { transform: translate($rmax*1px) }
100% { transform: translate($rmin*1px) }
}
这终于像我们想要的那样工作了!

我们可以在这里做的一件事是,为了简化代码,可以删除最初的$r1
和$r2
值,并将它们替换为每个动画的0%
关键帧中的值
path {
d: path('M#{-$r} 0A#{$r} #{$r} 0 0 1 #{$r} 0' +
'A#{$rmin} #{$rmin} 0 0 1 #{$r - 2*$rmin} 0' +
'A#{$rmax} #{$rmax} 0 0 0 #{-$r} 0');
animation: m $t ease-in-out infinite alternate
}
circle {
r: $rmin/3;
transform: translate($rmax*1px);
animation: s $t ease-in-out infinite alternate;
animation-name: s, x;
&:nth-child(3) {
cx: -$r;
animation-delay: -$t, 0s
}
}
@keyframes m {
to {
d: path('M#{-$r} 0A#{$r} #{$r} 0 0 1 #{$r} 0' +
'A#{$rmax} #{$rmax} 0 0 1 #{$r - 2*$rmax} 0' +
'A#{$rmin} #{$rmin} 0 0 0 #{-$r} 0');
}
}
@keyframes s { to { r: $rmax/3 } }
@keyframes x { to { transform: translate($rmin*1px) } }
视觉效果完全相同,只是代码更少了。
最后一步是让 SVG 元素本身无限旋转
svg { animation: r 2*$t linear infinite }
@keyframes r { to { transform: rotate(1turn) } }
可以在这个 Pen中看到完成的加载动画。
所以,这就是它——一个加载动画,四种不同的方法从头开始为网页重现它。我们在这里探索的并不是所有的内容在今天都可以在实践中使用。例如,对最后一种方法的支持非常差,性能也很糟糕。但是,探索如今正在成为可能的界限是一次有趣的练习,也是一个很好的学习机会。
太棒了。当我读到这篇文章的标题时,我没想到会有这么大的教程(有很多细微差别)来绘制阴阳加载器。
哇,太棒了。一下子要吸收的东西太多了,但详细的分步示例使其易于理解。(好吧,我已经忘记了一半,但我相信如果我足够努力,我可以向上滚动并再次跟随。)
我特别喜欢将内容分解出来的线条图,而不是直接跳跃。
每次我以为你完成了,你就继续用另一个实现。Canvas 实现是我最喜欢的;我永远不会厌倦老式的程序绘制。
现在,我想知道,是否可以使用仅使用转换(将外圈和内圈作为一个组缩放)的纯 CSS 方法来实现完全跨浏览器的 SVG + CSS 方法(无需路径操作)?
好吧,我想这应该是可能的,但不能使用主圆的单个路径。它只是类似于纯 HTML + CSS 方法,除了不是圆形的
div
,而是嵌套在group
中的circle
。对 SVG 元素使用转换的任何方法要么不是跨浏览器的,要么需要 JS。☹
对 SVG 元素使用 CSS 转换不是跨浏览器的(Edge 不支持它们)。SMIL 不是跨浏览器的(此外它还是标记呕吐物)。可以以跨浏览器的方式更改转换属性,但这需要 JS。
啊,我不知道 CSS 转换对 SVG 元素来说不是那么好。显然我错过了这篇文章:https://css-tricks.org.cn/transforms-on-svg-elements/
不是 SVG,但这里有一个稍微差一点的 CSS 实现
我认为这非常聪明,但我对这里如何使用哲学符号存在问题(对有些人来说,它也是一种宗教情感)。阴阳总是要平等地呈现,表明万物中总是存在着平等的正负。这种平衡是道教哲学的根本。一体两面的。显示它们相互增减真的与道教背道而驰。就像拿基督教十字架的胳膊,将它们拉伸到相等长度,然后再缩回来。阴阳的具体形状不仅仅是一个设计元素,它还代表着更深刻的含义。
下巴掉下来了 XD
太棒了,Ana!我非常喜欢你如何将半径保持同步并进行动画处理。另一个使用简单 CSS3 动画的实现
Ana,太棒了。我需要阅读 20 多遍才能理解,但我喜欢这种挑战!
观看这个加载器让我发疯 8-)
史诗级教程。
但是开发者应该浪费这么多时间来创建这种加载器吗?仍然存在 png 和 gif 动画;)