Braydon Coyer recently launched a monthly CSS art challenge. He actually had reached out to me about donating a copy of my book Move Things with CSS to use as a prize for the winner of the challenge — which I was more than happy to do!
The first month’s challenge? Spring. And when thinking of what to make for the challenge, Slinkys immediately came to mind. You know Slinkys, right? That classic toy you knock down the stairs and it travels with its own momentum.
![Animated Gif of a Slinky toy going down stairs.](https://i0.wp.com/css-tricks.com/wp-content/uploads/2022/05/giphy.gif?resize=500%2C375&ssl=1)
Can we create a Slinky walking down stairs like that in CSS? That’s exactly the sort of challenge I like, so I thought we could tackle that together in this article. Ready to roll? (Pun intended.)
Setting up the Slinky HTML
Let’s make this flexible. (No pun intended.) What I mean by that is we want to be able to control the Slinky’s behavior through CSS custom properties, giving us the flexibility of swapping values when we need to.
Here’s how I’m setting the scene, written in Pug for brevity:
- const RING_COUNT = 10;
.container
.scene
.plane(style=`--ring-count: ${RING_COUNT}`)
- let rings = 0;
while rings < RING_COUNT
.ring(style=`--index: ${rings};`)
- rings++;
Those inline custom properties are an easy way for us to update the number of rings and will come in handy as we get deeper into this challenge. The code above gives us 10
rings with HTML that looks something like this when compiled:
<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>
The initial Slinky CSS
We’re going to need some styles! What we want is a three-dimensional scene. I’m mindful of some things we may want to do later, so that’s the thinking behind having an extra wrapper component with a .scene
class.
Let’s start by defining some properties for our “infini-slinky” scene:
: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));
}
These properties define the characteristics of our Slinky and the scene. With the majority of 3D CSS scenes, we’re going to set transform-style
across the board:
* {
box-sizing: border-box;
transform-style: preserve-3d;
}
Now we need styles for our .scene
. The trick is to translate the .plane
so it looks like our CSS Slinky is moving infinitely down a flight of stairs. I had to play around to get things exactly the way I want, so bear with the magic number for now, as they’ll make sense later.
.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));
}
There is a fair bit going on here with the .container
transformation. Specifically:
translate3d(0, 0, 100vmin)
: This brings the.container
forward and stops our 3D work from getting cut off by the body. We aren’t usingperspective
at this level, so we can get away with it.rotateX(-24deg) rotateY(32deg)
: This rotates the scene based on our preferences.rotateX(90deg)
: This rotates the.container
by a quarter turn, which flattens the.scene
and.plane
by default, Otherwise, the two layers would look like the top and bottom of a 3D cube.translate3d(0, 0, calc((var(--depth) + var(--stack-height)) * -1))
: We can use this to move the scene and center it on the y-axis (well, actually the z-axis). This is in the eye of the designer. Here, we are using the--depth
and--stack-height
to center things.rotate(0deg)
: Although, not in use at the moment, we may want to rotate the scene or animate the rotation of the scene later.
To visualize what’s happening with the .container
, check this demo and tap anywhere to see the transform
applied (sorry, Chromium only. 😭):
We now have a styled scene! 💪
Styling the Slinky’s rings
This is where those CSS custom properties are going to play their part. We have the inlined properties --index
and --ring-count
from our HTML. We also have the predefined properties in the CSS that we saw earlier on the :root
.
The inline properties will play a part in positioning each ring:
.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);
}
Take note of how we are calculating the --origin-z
value as well as how we position each ring with the transform
property. That comes after positioning each ring with position: absolute
.
It is also worth noting how we’re alternating the color of each ring in that last ruleset. When I first implemented this, I wanted to create a rainbow slinky where the rings went through the hues. But that adds a bit of complexity to the effect.
Now we’ve got some rings on our raised .plane
:
Transforming the Slinky rings
It’s time to get things moving! You may have noticed that we set a transform-origin
on each .ring
like this:
.ring {
transform-origin: calc(100% + (var(--scene-size) * 0.2)) 50%;
}
This is based on the .scene
size. That 0.2
value is half the remaining available size of the .scene
after the .ring
is positioned.
We could tidy this up a bit for sure!
: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);
}
Why that transform-origin
? Well, we need the ring to look like is moving off-center. Playing with the transform
of an individual ring is a good way to work out the transform
we want to apply. Move the slider on this demo to see the ring flip:
Add all the rings back and we can flip the whole stack!
Hmm, but they aren’t falling to the next stair. How can we make each ring fall to the right position?
Well, we have a calculated --origin-z
, so let’s calculate --destination-z
so the depth changes as the rings transform
. If we have a ring on top of the stack, it should wind up at the bottom after it falls. We can use our custom properties to scope a destination for each ring:
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));
}
Now try moving the stack! We’re getting there. 🙌
Animating the rings
We want our ring to flip and then fall. A first attempt might look something like this:
.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);
}
}
Oof, that’s not right at all!
But that’s only because we aren’t using animation-delay
. All the rings are, um, slinking at the same time. Let’s introduce an animation-delay
based on the --index
of the ring so they slink in succession.
.ring {
animation-delay: calc(var(--index) * 0.1s);
}
OK, that is indeed “better.” But the timing is still off. What sticks out more, though, is the shortcoming of animation-delay
. It is only applied on the first animation iteration. After that, we lose the effect.
At this point, let’s color the rings so they progress through the hue wheel. This is going to make it easier to see what’s going on.
.ring {
--hue: calc((360 / var(--ring-count)) * var(--index));
}
That’s better! ✨
Back to the issue. Because we are unable to specify a delay that’s applied to every iteration, we are also unable to get the effect we want. For our Slinky, if we were able to have a consistent animation-delay
, we might be able to achieve the effect we want. And we could use one keyframe while relying on our scoped custom properties. Even an animation-repeat-delay
could be an interesting addition.
This functionality is available in JavaScript animation solutions. For example, GreenSock allows you to specify a delay
and a repeatDelay
.
But, our Slinky example isn’t the easiest thing to illustrate this problem. Let’s break this down into a basic example. Consider two boxes. And you want them to alternate spinning.
How do we do this with CSS and no “tricks”? One idea is to add a delay to one of the boxes:
.box {
animation: spin 1s var(--delay, 0s) infinite;
}
.box:nth-of-type(2) {
--delay: 1s;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
But, that won’t work because the red box will keep spinning. And so will the blue one after its initial animation-delay
.
With something like GreenSock, though, we can achieve the effect we want with relative ease:
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',
})
And there it is!
But how can we do this without JavaScript?
Well, we have to “hack” our @keyframes
and completely do away with animation-delay
. Instead, we will pad out the @keyframes
with empty space. This comes with various quirks, but let’s go ahead and build a new keyframe first. This will fully rotate the element twice:
@keyframes spin {
50%, 100% {
transform: rotate(360deg);
}
}
It’s like we’ve cut the keyframe in half. And now we’ll have to double the animation-duration
to get the same speed. Without using animation-delay
, we could try setting animation-direction: reverse
on the second box:
.box {
animation: spin 2s infinite;
}
.box:nth-of-type(2) {
animation-direction: reverse;
}
Almost.
The rotation is the wrong way round. We could use a wrapper element and rotate that, but that could get tricky as there are more things to balance. The other approach is to create two keyframes instead of one:
@keyframes box-one {
50%, 100% {
transform: rotate(360deg);
}
}
@keyframes box-two {
0%, 50% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
And there we have it:
This would’ve been a lot easier if we had a way to specify the repeat delay with something like this:
/* Hypothetical! */
animation: spin 1s 0s 1s infinite;
Or if the repeated delay matched the initial delay, we could possibly have a combinator for it:
/* Hypothetical! */
animation: spin 1s 1s+ infinite;
It would make for an interesting addition for sure!
So, we need keyframes for all those rings?
Yes, that is, if we want a consistent delay. And we need to do that based on what we are going to use as the animation window. All the rings need to have “slinked” and settled before the keyframes repeat.
This would be horrible to write out by hand. But this is why we have CSS preprocessors, right? Well, at least until we get loops and some extra custom property features on the web. 😉
Today’s weapon of choice will be Stylus. It’s my favorite CSS preprocessor and has been for some time. Habit means I haven’t moved to Sass. Plus, I like Stylus’s lack of required grammar and flexibility.
Good thing we only need to write this once:
// 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)
}
}
Here’s what those variables mean:
$ring-count
: The number of rings in our slinky.$animation-window
: This is the percentage of the keyframe that we can slink in. In our example, we’re saying we want to slink over50%
of the keyframes. The remaining50%
should get used for delays.$animation-step
: This is the calculated stagger for each ring. We can use this to calculate the unique keyframe percentages for each ring.
Here’s how it compiles to CSS, at least for the first couple of iterations:
View full code
@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);
}
}
The last thing to do is apply each set of keyframes to each ring. We can do this using our markup if we want by updating it to define both an --index
and a --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++;
Which gives us this when compiled:
<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>
And then our styling can be updated accordingly:
.ring {
animation: var(--name) var(--speed) both infinite cubic-bezier(0.25, 0, 1, 1);
}
Timing is everything. So we’ve ditched the default animation-timing-function
and we’re using a cubic-bezier
. We’re also making use of the --speed
custom property we defined at the start.
Aw yeah. Now we have a slinking CSS Slinky! Have a play with some of the variables in the code and see what different behavior you can yield.
Creating an infinite animation
Now that we have the hardest part out of the way, we can make get this to where the animation repeats infinitely. To do this, we’re going to translate the scene as our Slinky slinks so it looks like it is slinking back into its original position.
.scene {
animation: step-up var(--speed) infinite linear both;
}
@keyframes step-up {
to {
transform: translate3d(-100%, 0, var(--depth));
}
}
Wow, that took very little effort!
We can remove the platform colors from .scene
and .plane
to prevent the animation from being too jarring:
Almost done! The last thing to address is that the stack of rings flips before it slinks again. This is where we mentioned earlier that the use of color would come in handy. Change the number of rings to an odd number, like 11
, and switch back to alternating the ring color:
Boom! We have a working CSS slinky! It’s configurable, too!
Fun variations
How about a “flip flop” effect? By that, I mean getting the Slink to slink alternate ways. If we add an extra wrapper element to the scene, we could rotate the scene by 180deg
on each 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++;
As far as animation goes, we can make use of the steps()
timing function and use twice the --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);
}
}
Last, but not least, let’s change the way the .scene
element’s step-up
animation works. It no longer needs to move on the x-axis.
@keyframes step-up {
0% {
transform: translate3d(-50%, 0, 0);
}
100% {
transform: translate3d(-50%, 0, var(--depth));
}
}
Note the animation-timing-function
that we use. That use of steps(1)
is what makes it possible.
If you want another fun use of steps()
, check out this #SpeedyCSSTip!
For an extra touch, we could rotate the whole scene slow:
.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);
}
}
I like it! Of course, styling is subjective… so, I made a little app you can use configure your Slinky:
And here are the “Original” and “Flip-Flop” versions I took a little further with shadows and theming.
Final demos
That’s it!
That’s at least one way to make a pure CSS Slinky that’s both 3D and configurable. Sure, you might not reach for something like this every day, but it brings up interesting CSS animation techniques. It also raises the question of whether having a animation-repeat-delay
property in CSS would be useful. What do you think? Do you think there would be some good use cases for it? I’d love to know.
Be sure to have a play with the code — all of it is available in this CodePen Collection!
I think that this could be achieved without creating different keyframes for each animated element. You could use a single set of keyframes for all elements, and then use negative
animation-delay
properties to start each at a different point in the animation.In the example you created with the two spinning boxes, you could apply the following keyframes to both with a 2s duration:
Then apply
animation-delay: -1s
to the second box. The boxes will alternate their spins.Yep! You totally could do it this way. That “basic” example is merely that. It’s a “basic” way to try and show the issue. When things get more complex, this approach becomes hard to scale with CSS as it currently stands. In fact, one of the demos shows how to use the same keyframes with different delays for a one-time slink. But, when we try adding multiple rings and keeping a consistent delay timing for all the rings infinitely. It gets harder to scale.
This is more about exploring things with CSS and ways in which we might achieve things or what things we might like to see introduced into the language as features. We’re always open to hearing views from the community about what they’d like to see in CSS, etc.
All the 3D stuff is somewhat above my head, but for the repeatitive delay for the alternating rotation boxes it seems that you can achieve it with a single
@keyframes
. My point is that you can combineanimation-delay
with the “halted” part of the@keyframes
:Here is a fork for a live demonstration:
https://codepen.io/AnisMan/pen/NWyRJVd
Yep! You totally could do it this way. That “basic” example is merely that. It’s a “basic” way to try and show the issue. When things get more complex, this approach becomes hard to scale with CSS as it currently stands.
This is more about exploring things with CSS and ways in which we might achieve things or what things we might like to see introduced into the language as features.
Damn! This was an extremely interesting and thorough article. Thanks for taking the time. So cool to see what you can do just with css.