Braydon Coyer最近发起了每月一次的CSS艺术挑战。实际上,他曾与我联系过,希望我捐出一本我的书 用CSS移动东西作为挑战赛获胜者的奖品--我非常乐意这样做!"。
第一个月的挑战是什么?春天。当想到要为这个挑战做什么时,我马上想到了Slinkys。你知道Slinkys,对吗?那种经典的玩具,你把它从楼梯上敲下来,它就会以自己的动力行驶。
滑动的Slinky
我们能在CSS中创建一个像这样走下楼梯的Slinky吗?这正是我喜欢的挑战,所以我想我们可以在这篇文章中一起解决这个问题。准备好开始了吗?(双关语)。
设置Slinky的HTML
让我们把它变得灵活。(我的意思是,我们希望能够通过CSS的自定义属性来控制Slinky的行为,使我们能够在需要的时候灵活地交换数值。
下面是我如何设置场景的,为了简洁起见,用Pug写的:
- const RING_COUNT = 10;
.container
.scene
.plane(style=`--ring-count: ${RING_COUNT}`)
- let rings = 0;
while rings < RING_COUNT
.ring(style=`--index: ${rings};`)
- rings++;
这些内嵌的自定义属性是我们更新戒指数量的一个简单方法,随着我们对这一挑战的深入,它将派上用场。上面的代码给我们提供了10 环的HTML,编译后看起来像这样。
<div class="container">
<div class="scene">
<div class="plane" style="--ring-count: 10">
<div class="ring" style="--index: 0;"></div>
<div class="ring" style="--index: 1;"></div>
<div class="ring" style="--index: 2;"></div>
<div class="ring" style="--index: 3;"></div>
<div class="ring" style="--index: 4;"></div>
<div class="ring" style="--index: 5;"></div>
<div class="ring" style="--index: 6;"></div>
<div class="ring" style="--index: 7;"></div>
<div class="ring" style="--index: 8;"></div>
<div class="ring" style="--index: 9;"></div>
</div>
</div>
</div>
最初的Slinky CSS
我们将需要一些样式!我们想要的是一个三维的场景。我注意到我们以后可能要做的一些事情,所以这就是有一个额外的包装组件与.scene 类背后的想法。
让我们先为我们的 "无限的-slinky "场景定义一些属性:
:root {
--border-width: 1.2vmin;
--depth: 20vmin;
--stack-height: 6vmin;
--scene-size: 20vmin;
--ring-size: calc(var(--scene-size) * 0.6);
--plane: radial-gradient(rgb(0 0 0 / 0.1) 50%, transparent 65%);
--ring-shadow: rgb(0 0 0 / 0.5);
--hue-one: 320;
--hue-two: 210;
--blur: 10px;
--speed: 1.2s;
--bg: #fafafa;
--ring-filter: brightness(1) drop-shadow(0 0 0 var(--accent));
}
这些属性定义了我们的Slinky和场景的特性。对于大多数的3D CSS场景,我们要全面地设置transform-style :
* {
box-sizing: border-box;
transform-style: preserve-3d;
}
现在我们需要为我们的.scene 的样式。诀窍在于翻译.plane ,这样看起来就像我们的CSS Slinky在无限地沿着楼梯移动。我不得不四处游玩,以使事情完全符合我的要求,所以现在先忍受一下这个神奇的数字,因为它们以后会有意义。
.container {
/* Define the scene's dimensions */
height: var(--scene-size);
width: var(--scene-size);
/* Add depth to the scene */
transform:
translate3d(0, 0, 100vmin)
rotateX(-24deg) rotateY(32deg)
rotateX(90deg)
translateZ(calc((var(--depth) + var(--stack-height)) * -1))
rotate(0deg);
}
.scene,
.plane {
/* Ensure our container take up the full .container */
height: 100%;
width: 100%;
position: relative;
}
.scene {
/* Color is arbitrary */
background: rgb(162 25 230 / 0.25);
}
.plane {
/* Color is arbitrary */
background: rgb(25 161 230 / 0.25);
/* Overrides the previous selector */
transform: translateZ(var(--depth));
}
.container ,这里有很多事情要做。具体而言。
translate3d(0, 0, 100vmin): 这使.container,阻止我们的三维工作被身体切断。我们在这个层面上没有使用perspective,所以我们可以不使用它。rotateX(-24deg) rotateY(32deg): 这将根据我们的偏好来旋转场景。rotateX(90deg): 这将使.container旋转四分之一圈,默认情况下,这将使.scene和.plane变平,否则,这两层看起来就像一个三维立方体的顶部和底部。translate3d(0, 0, calc((var(--depth) + var(--stack-height)) * -1)): 我们可以用它来移动场景,并把它放在Y轴(好吧,实际上是Z轴)的中心。这是在设计者的眼中。在这里,我们正在使用--depth和--stack-height来居中。rotate(0deg): 虽然,目前没有使用,但我们可能想在以后旋转场景或对场景的旋转制作动画。
为Slinky的圆环设计样式
这就是那些CSS自定义属性要发挥其作用的地方。我们有内嵌的属性--index 和--ring-count 来自我们的HTML。我们在CSS中也有预定义的属性,我们在前面看到:root 。
内联属性将在每个环的定位中发挥一定作用:
.ring {
--origin-z:
calc(
var(--stack-height) - (var(--stack-height) / var(--ring-count))
* var(--index)
);
--hue: var(--hue-one);
--accent: hsl(var(--hue) 100% 55%);
height: var(--ring-size);
width: var(--ring-size);
border-radius: 50%;
border: var(--border-width) solid var(--accent);
position: absolute;
top: 50%;
left: 50%;
transform-origin: calc(100% + (var(--scene-size) * 0.2)) 50%;
transform:
translate3d(-50%, -50%, var(--origin-z))
translateZ(0)
rotateY(0deg);
}
.ring:nth-of-type(odd) {
--hue: var(--hue-two);
}
请注意我们是如何计算--origin-z ,以及如何用transform 属性定位每个环。这是在用position: absolute 定位每个环之后。
同样值得注意的是,在最后一个规则组中,我们是如何交替使用每个环的颜色的。当我第一次实现这一点时,我想创造一个彩虹式的吊环,让吊环穿过各种色调。但这给效果增加了一点复杂性。
现在我们已经有了一些环在我们提出的.plane 。
变换Slinky的环
现在是时候让东西动起来了!你可能已经注意到,我们在每个.ring ,像这样设置一个transform-origin :
.ring {
transform-origin: calc(100% + (var(--scene-size) * 0.2)) 50%;
}
这是基于.scene 的大小。这个0.2 的值是.ring 定位后.scene 剩余可用尺寸的一半。
我们当然可以把它整理得更整齐一些
:root {
--ring-percentage: 0.6;
--ring-size: calc(var(--scene-size) * var(--ring-percentage));
--ring-transform:
calc(
100%
+ (var(--scene-size) * ((1 - var(--ring-percentage)) * 0.5))
) 50%;
}
.ring {
transform-origin: var(--ring-transform);
}
为什么是transform-origin ?好吧,我们需要让这个环看起来像在偏离中心移动。玩弄单个环的transform ,是一个很好的方法来制定我们想要应用的transform 。
嗯,但它们并没有落到下一个台阶上。我们怎样才能使每个环落到正确的位置呢?
好吧,我们有一个计算好的--origin-z ,所以让我们计算一下--destination-z ,这样深度就会随着环的变化而变化transform 。如果我们有一个环在堆栈的顶部,它应该在落下后缠绕在底部。我们可以使用我们的自定义属性来为每个环设定一个目标范围。
ring {
--destination-z: calc(
(
(var(--depth) + var(--origin-z))
- (var(--stack-height) - var(--origin-z))
) * -1
);
transform-origin: var(--ring-transform);
transform:
translate3d(-50%, -50%, var(--origin-z))
translateZ(calc(var(--destination-z) * var(--flipped, 0)))
rotateY(calc(var(--flipped, 0) * 180deg));
}
现在试着移动堆栈吧!我们正在取得进展。 🙌
给环做动画
我们希望我们的环能够翻转,然后落下。第一次尝试可能看起来像这样。
.ring {
animation-name: slink;
animation-duration: 2s;
animation-fill-mode: both;
animation-iteration-count: infinite;
}
@keyframes slink {
0%, 5% {
transform:
translate3d(-50%, -50%, var(--origin-z))
translateZ(0)
rotateY(0deg);
}
25% {
transform:
translate3d(-50%, -50%, var(--origin-z))
translateZ(0)
rotateY(180deg);
}
45%, 100% {
transform:
translate3d(-50%, -50%, var(--origin-z))
translateZ(var(--destination-z))
rotateY(180deg);
}
}
哦,这根本就不对!
但这只是因为我们没有使用animation-delay 。所有的环都是,嗯,在同一时间内滑行。让我们根据环的--index ,引入一个animation-delay ,这样它们就可以连续地滑行了。
.ring {
animation-delay: calc(var(--index) * 0.1s);
}
好吧,这的确是 "更好"。 但时间上还是有偏差。不过,更突出的是animation-delay 的缺点。它只在第一次动画迭代时应用。在那之后,我们就失去了这个效果。
在这一点上,让我们给圆环上色,让它们在色相轮中前进。这将使我们更容易看到正在发生的事情。
.ring {
--hue: calc((360 / var(--ring-count)) * var(--index));
}
这样就好了!✨
回到问题上来。因为我们无法指定一个应用于每个迭代的延迟,我们也无法得到我们想要的效果。对于我们的Slinky,如果我们能够有一个一致的animation-delay ,我们也许能够实现我们想要的效果。而且我们可以使用一个关键帧,同时依靠我们的范围内的自定义属性。甚至一个animation-repeat-delay ,也可能是一个有趣的补充。
这种功能在JavaScript动画解决方案中也有。例如,GreenSock允许你指定一个delay 和一个repeatDelay 。
但是,我们的Slinky例子并不是说明这个问题的最简单的东西。让我们把它分解成一个基本的例子。考虑两个盒子。而你想让它们交替旋转。
我们如何用CSS和没有 "技巧 "来做这个?一个想法是给其中一个盒子添加一个延迟。
.box {
animation: spin 1s var(--delay, 0s) infinite;
}
.box:nth-of-type(2) {
--delay: 1s;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
但是,那是行不通的,因为红色盒子会一直旋转。而蓝色的也会在其最初的animation-delay 。
不过,有了GreenSock这样的东西,我们可以相对容易地实现我们想要的效果。
import gsap from 'https://cdn.skypack.dev/gsap'
gsap.to('.box', {
rotate: 360,
/**
* A function based value, means that the first box has a delay of 0 and
* the second has a delay of 1
*/
delay: (index) > index,
repeatDelay: 1,
repeat: -1,
ease: 'power1.inOut',
})
它就在那里!
但是,如果没有JavaScript,我们怎么能做到这一点呢?
好吧,我们必须 "入侵 "我们的@keyframes ,并完全取消animation-delay 。相反,我们将用空的空间来填充@keyframes 。这有各种怪癖,但让我们先去建立一个新的关键帧。这将使该元素完全旋转两次。
@keyframes spin {
50%, 100% {
transform: rotate(360deg);
}
}
这就像我们把关键帧切成了两半。而现在我们必须将animation-duration ,以获得相同的速度。如果不使用animation-delay ,我们可以尝试在第二个盒子上设置animation-direction: reverse 。
.box {
animation: spin 2s infinite;
}
.box:nth-of-type(2) {
animation-direction: reverse;
}
差不多了。
旋转的方式是错误的。我们可以使用一个包装元素并旋转它,但这可能会变得很棘手,因为有更多的东西需要平衡。另一种方法是创建两个关键帧而不是一个。
@keyframes box-one {
50%, 100% {
transform: rotate(360deg);
}
}
@keyframes box-two {
0%, 50% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
就这样,我们有了它。
如果我们有办法用这样的方法来指定重复延迟,这就会容易得多。
/* Hypothetical! */
animation: spin 1s 0s 1s infinite;
或者,如果重复延迟与初始延迟相匹配,我们可以为它设置一个组合器。
/* Hypothetical! */
animation: spin 1s 1s+ infinite;
这肯定会成为一个有趣的补充!
那么,我们需要为所有这些环提供关键帧?
是的,也就是说,如果我们想要一个一致的延迟。而且我们需要根据我们要使用的动画窗口来做。在关键帧重复之前,所有的环都需要 "滑行 "并稳定下来。
如果用手写出来,那就太可怕了。但这就是我们有CSS预处理器的原因,对吗?好吧,至少在我们得到循环和网络上一些额外的自定义属性功能之前。
今天的武器将是Stylus。它是我最喜欢的CSS预处理程序,而且已经有一段时间了。习惯意味着我还没有转移到Sass。另外,我喜欢Stylus缺乏必要的语法和灵活性。
好在我们只需要写这一次。
// STYLUS GENERATED KEYFRAMES BE HERE...
$ring-count = 10
$animation-window = 50
$animation-step = $animation-window / $ring-count
for $ring in (0..$ring-count)
// Generate a set of keyframes based on the ring index
// index is the ring
$start = $animation-step * ($ring + 1)
@keyframes slink-{$ring} {
// In here is where we need to generate the keyframe steps based on ring count and window.
0%, {$start * 1%} {
transform
translate3d(-50%, -50%, var(--origin-z))
translateZ(0)
rotateY(0deg)
}
// Flip without falling
{($start + ($animation-window * 0.75)) * 1%} {
transform
translate3d(-50%, -50%, var(--origin-z))
translateZ(0)
rotateY(180deg)
}
// Fall until the cut-off point
{($start + $animation-window) * 1%}, 100% {
transform
translate3d(-50%, -50%, var(--origin-z))
translateZ(var(--destination-z))
rotateY(180deg)
}
}
下面是这些变量的含义:
$ring-count: 我们的滑轮上的环的数量。$animation-window: 这是我们可以在关键帧中滑行的百分比。在我们的例子中,我们说我们要在50%的关键帧上滑行。剩余的50%应该被用于延迟。$animation-step: 这是计算出的每个环的错开时间。我们可以用它来计算每个环的独特关键帧百分比。
下面是它是如何编译成CSS的,至少在最初的几个迭代中是这样。
查看完整代码
@keyframes slink-0 {
0%, 4.5% {
transform:
translate3d(-50%, -50%, var(--origin-z))
translateZ(0)
rotateY(0deg);
}
38.25% {
transform:
translate3d(-50%, -50%, var(--origin-z))
translateZ(0)
rotateY(180deg);
}
49.5%, 100% {
transform:
translate3d(-50%, -50%, var(--origin-z))
translateZ(var(--destination-z))
rotateY(180deg);
}
}
@keyframes slink-1 {
0%, 9% {
transform:
translate3d(-50%, -50%, var(--origin-z))
translateZ(0)
rotateY(0deg);
}
42.75% {
transform:
translate3d(-50%, -50%, var(--origin-z))
translateZ(0)
rotateY(180deg);
}
54%, 100% {
transform:
translate3d(-50%, -50%, var(--origin-z))
translateZ(var(--destination-z))
rotateY(180deg);
}
}
最后要做的是将每组关键帧应用到每个环上。如果我们想的话,我们可以通过更新我们的标记来完成这个工作,同时定义一个--index 和一个--name 。
- const RING_COUNT = 10;
.container
.scene
.plane(style=`--ring-count: ${RING_COUNT}`)
- let rings = 0;
while rings < RING_COUNT
.ring(style=`--index: ${rings}; --name: slink-${rings};`)
- rings++;
编译后我们就可以得到这个:
<div class="container">
<div class="scene">
<div class="plane" style="--ring-count: 10">
<div class="ring" style="--index: 0; --name: slink-0;"></div>
<div class="ring" style="--index: 1; --name: slink-1;"></div>
<div class="ring" style="--index: 2; --name: slink-2;"></div>
<div class="ring" style="--index: 3; --name: slink-3;"></div>
<div class="ring" style="--index: 4; --name: slink-4;"></div>
<div class="ring" style="--index: 5; --name: slink-5;"></div>
<div class="ring" style="--index: 6; --name: slink-6;"></div>
<div class="ring" style="--index: 7; --name: slink-7;"></div>
<div class="ring" style="--index: 8; --name: slink-8;"></div>
<div class="ring" style="--index: 9; --name: slink-9;"></div>
</div>
</div>
</div>
然后我们的造型就可以相应地更新:
.ring {
animation: var(--name) var(--speed) both infinite cubic-bezier(0.25, 0, 1, 1);
}
时机就是一切。所以我们放弃了默认的animation-timing-function ,我们使用了cubic-bezier 。我们还利用了我们在开始时定义的--speed 自定义属性。
噢,是的。现在我们有了一个滑溜溜的CSS Slinky!玩玩代码中的一些变量,看看你能产生什么不同的行为。
创建一个无限的动画
现在我们已经解决了最困难的部分,我们可以让这个动画无限地重复。要做到这一点,我们要在我们的Slinky滑行时对场景进行翻译,这样它看起来就像滑回了它的原始位置。
.scene {
animation: step-up var(--speed) infinite linear both;
}
@keyframes step-up {
to {
transform: translate3d(-100%, 0, var(--depth));
}
}
哇,这花了很少的功夫
我们可以从.scene 和.plane 删除平台的颜色,以防止动画过于刺眼。
有趣的变化
一个 "翻转 "的效果如何?我的意思是让Slink以不同的方式滑行。如果我们在场景中添加一个额外的包装元素,我们可以通过180deg 在每个slink上旋转场景。
- const RING_COUNT = 11;
.container
.flipper
.scene
.plane(style=`--ring-count: ${RING_COUNT}`)
- let rings = 0;
while rings < RING_COUNT
.ring(style=`--index: ${rings}; --name: slink-${rings};`)
- rings++;
就动画而言,我们可以利用steps() 的计时功能,使用两倍的--speed 。
.flipper {
animation: flip-flop calc(var(--speed) * 2) infinite steps(1);
height: 100%;
width: 100%;
}
@keyframes flip-flop {
0% {
transform: rotate(0deg);
}
50% {
transform: rotate(180deg);
}
100% {
transform: rotate(360deg);
}
}
最后,但不是最不重要的,让我们改变一下.scene 元素的step-up 动画的工作方式。它不再需要在X轴上移动。
@keyframes step-up {
0% {
transform: translate3d(-50%, 0, 0);
}
100% {
transform: translate3d(-50%, 0, var(--depth));
}
}
注意我们使用的animation-timing-function 。这种对steps(1) 的使用使其成为可能。
如果你想要另一个有趣的使用steps() ,请查看这个#SpeedyCSSTip!
对于一个额外的触摸,我们可以缓慢地旋转整个场景。
.container {
animation: rotate calc(var(--speed) * 40) infinite linear;
}
@keyframes rotate {
to {
transform:
translate3d(0, 0, 100vmin)
rotateX(-24deg)
rotateY(-32deg)
rotateX(90deg)
translateZ(calc((var(--depth) + var(--stack-height)) * -1))
rotate(360deg);
}
}
我喜欢它!当然,造型是主观的......所以,我做了一个小应用程序,你可以使用配置你的Slinky。
这里是 "原始 "和 "Flip-Flop "版本,我用阴影和主题做了一些进一步的处理。
这就是了!
这至少是一种制作纯CSS Slinky的方法,它既是3D的又是可配置的。当然,你可能不会每天都去找这样的东西,但它带来了有趣的CSS动画技术。它还提出了一个问题:在CSS中设置一个animation-repeat-delay 属性是否有用。你怎么看?你认为它是否会有一些好的使用案例?我很想知道。
请一定要玩一玩这些代码--所有的代码都可以在这个CodePen Collection中找到。