Braydon Coyer just lately launched a month-to-month CSS art challenge. He truly had reached out to me about donating a duplicate of my ebook Move Things with CSS to make use of as a prize for the winner of the problem — which I used to be more than pleased to do!
The primary month’s problem? Spring. And when considering of what to make for the problem, Slinkys instantly got here to thoughts. You recognize Slinkys, proper? That traditional toy you knock down the steps and it travels with its personal momentum.

Can we create a Slinky strolling down stairs like that in CSS? That’s precisely the kind of problem I like, so I assumed we might sort out that collectively on this article. Able to roll? (Pun meant.)
Organising the Slinky HTML
Let’s make this versatile. (No pun meant.) What I imply by that’s we wish to have the ability to management the Slinky’s habits by CSS customized properties, giving us the flexibleness of swapping values when we have to.
Right here’s how I’m setting the scene, written in Pug for brevity:
- const RING_COUNT = 10;
.container
.scene
.aircraft(fashion=`--ring-count: ${RING_COUNT}`)
- let rings = 0;
whereas rings < RING_COUNT
.ring(fashion=`--index: ${rings};`)
- rings++;
These inline customized properties are a simple manner for us to replace the variety of rings and can come in useful as we get deeper into this problem. The code above provides us 10
rings with HTML that appears something like this when compiled:
<div class="container">
<div class="scene">
<div class="aircraft" fashion="--ring-count: 10">
<div class="ring" fashion="--index: 0;"></div>
<div class="ring" fashion="--index: 1;"></div>
<div class="ring" fashion="--index: 2;"></div>
<div class="ring" fashion="--index: 3;"></div>
<div class="ring" fashion="--index: 4;"></div>
<div class="ring" fashion="--index: 5;"></div>
<div class="ring" fashion="--index: 6;"></div>
<div class="ring" fashion="--index: 7;"></div>
<div class="ring" fashion="--index: 8;"></div>
<div class="ring" fashion="--index: 9;"></div>
</div>
</div>
</div>
The preliminary Slinky CSS
We’re going to wish some kinds! What we wish is a three-dimensional scene. I’m aware of some issues we could wish to do later, in order that’s the considering behind having an additional wrapper part with a .scene
class.
Let’s begin 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%, clear 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 outline the traits of our Slinky and the scene. With the vast majority of 3D CSS scenes, we’re going to set transform-style
throughout the board:
* {
box-sizing: border-box;
transform-style: preserve-3d;
}
Now we want kinds for our .scene
. The trick is to translate the .aircraft
so it seems like our CSS Slinky is transferring infinitely down a flight of stairs. I needed to mess around to get issues precisely the best way I would like, so bear with the magic quantity for now, as they’ll make sense later.
.container {
/* Outline the scene's dimensions */
top: var(--scene-size);
width: var(--scene-size);
/* Add depth to the scene */
rework:
translate3d(0, 0, 100vmin)
rotateX(-24deg) rotateY(32deg)
rotateX(90deg)
translateZ(calc((var(--depth) + var(--stack-height)) * -1))
rotate(0deg);
}
.scene,
.aircraft {
/* Guarantee our container take up the total .container */
top: 100%;
width: 100%;
place: relative;
}
.scene {
/* Colour is unfair */
background: rgb(162 25 230 / 0.25);
}
.aircraft {
/* Colour is unfair */
background: rgb(25 161 230 / 0.25);
/* Overrides the earlier selector */
rework: translateZ(var(--depth));
}
There’s a good bit occurring right here with the .container
transformation. Particularly:
translate3d(0, 0, 100vmin)
: This brings the.container
ahead and stops our 3D work from getting minimize off by the physique. We aren’t utilizingperspective
at this stage, so we will get away with it.rotateX(-24deg) rotateY(32deg)
: This rotates the scene based mostly on our preferences.rotateX(90deg)
: This rotates the.container
by 1 / 4 flip, which flattens the.scene
and.aircraft
by default, In any other case, the 2 layers would appear to be the highest and backside of a 3D dice.translate3d(0, 0, calc((var(--depth) + var(--stack-height)) * -1))
: We are able to use this to maneuver the scene and middle it on the y-axis (properly, truly the z-axis). That is within the eye of the designer. Right here, we’re utilizing the--depth
and--stack-height
to middle issues.rotate(0deg)
: Though, not in use in the intervening time, we could wish to rotate the scene or animate the rotation of the scene later.
To visualise what’s taking place with the .container
, test this demo and faucet anyplace to see the rework
utilized (sorry, Chromium solely. 😭):
We now have a styled scene! 💪
Styling the Slinky’s rings
That is the place these CSS customized properties are going to play their half. We have now the inlined properties --index
and --ring-count
from our HTML. We even have the predefined properties within the CSS that we noticed earlier on the :root
.
The inline properties will play a component in positioning every 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%);
top: var(--ring-size);
width: var(--ring-size);
border-radius: 50%;
border: var(--border-width) stable var(--accent);
place: absolute;
high: 50%;
left: 50%;
transform-origin: calc(100% + (var(--scene-size) * 0.2)) 50%;
rework:
translate3d(-50%, -50%, var(--origin-z))
translateZ(0)
rotateY(0deg);
}
.ring:nth-of-type(odd) {
--hue: var(--hue-two);
}
Be aware of how we’re calculating the --origin-z
worth in addition to how we place every ring with the rework
property. That comes after positioning every ring with place: absolute
.
It’s also value noting how we’re alternating the colour of every ring in that final ruleset. After I first carried out this, I needed to create a rainbow slinky the place the rings went by the hues. However that provides a little bit of complexity to the impact.
Now we’ve bought some rings on our raised .aircraft
:
Remodeling the Slinky rings
It’s time to get issues transferring! You’ll have seen that we set a transform-origin
on every .ring
like this:
.ring {
transform-origin: calc(100% + (var(--scene-size) * 0.2)) 50%;
}
That is based mostly on the .scene
dimension. That 0.2
worth is half the remaining obtainable dimension of the .scene
after the .ring
is positioned.
We might tidy this up a bit for certain!
: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
? Nicely, we want the ring to appear to be is transferring off-center. Taking part in with the rework
of a person ring is an efficient method to work out the rework
we wish to apply. Transfer the slider on this demo to see the ring flip:
Add all of the rings again and we will flip the entire stack!
Hmm, however they aren’t falling to the following stair. How can we make every ring fall to the appropriate place?
Nicely, we now have a calculated --origin-z
, so let’s calculate --destination-z
so the depth adjustments because the rings rework
. If we now have a hoop on high of the stack, it ought to wind up on the backside after it falls. We are able to use our customized properties to scope a vacation spot for every ring:
ring {
--destination-z: calc(
(
(var(--depth) + var(--origin-z))
- (var(--stack-height) - var(--origin-z))
) * -1
);
transform-origin: var(--ring-transform);
rework:
translate3d(-50%, -50%, var(--origin-z))
translateZ(calc(var(--destination-z) * var(--flipped, 0)))
rotateY(calc(var(--flipped, 0) * 180deg));
}
Now attempt transferring the stack! We’re getting there. 🙌
Animating the rings
We wish our ring to flip after which fall. A primary try would possibly look one thing like this:
.ring {
animation-name: slink;
animation-duration: 2s;
animation-fill-mode: each;
animation-iteration-count: infinite;
}
@keyframes slink {
0%, 5% {
rework:
translate3d(-50%, -50%, var(--origin-z))
translateZ(0)
rotateY(0deg);
}
25% {
rework:
translate3d(-50%, -50%, var(--origin-z))
translateZ(0)
rotateY(180deg);
}
45%, 100% {
rework:
translate3d(-50%, -50%, var(--origin-z))
translateZ(var(--destination-z))
rotateY(180deg);
}
}
Oof, that’s not proper in any respect!
However that’s solely as a result of we aren’t utilizing animation-delay
. All of the rings are, um, slinking on the identical time. Let’s introduce an animation-delay
based mostly on the --index
of the ring so that they slink in succession.
.ring {
animation-delay: calc(var(--index) * 0.1s);
}
OK, that’s certainly “higher.” However the timing continues to be off. What stands proud extra, although, is the shortcoming of animation-delay
. It’s only utilized on the primary animation iteration. After that, we lose the impact.
At this level, let’s shade the rings so that they progress by the hue wheel. That is going to make it simpler to see what’s occurring.
.ring {
--hue: calc((360 / var(--ring-count)) * var(--index));
}
That’s higher! ✨
Again to the problem. As a result of we’re unable to specify a delay that’s utilized to each iteration, we’re additionally unable to get the impact we wish. For our Slinky, if we have been in a position to have a constant animation-delay
, we would have the ability to obtain the impact we wish. And we might use one keyframe whereas counting on our scoped customized properties. Even an animation-repeat-delay
might be an attention-grabbing addition.
This performance is offered in JavaScript animation options. For instance, GreenSock permits you to specify a delay
and a repeatDelay
.
However, our Slinky instance isn’t the simplest factor as an example this downside. Let’s break this down right into a fundamental instance. Think about two containers. And also you need them to alternate spinning.
How can we do that with CSS and no “methods”? One thought is so as to add a delay to one of many containers:
.field {
animation: spin 1s var(--delay, 0s) infinite;
}
.field:nth-of-type(2) {
--delay: 1s;
}
@keyframes spin {
to {
rework: rotate(360deg);
}
}
However, that gained’t work as a result of the purple field will hold spinning. And so will the blue one after its preliminary animation-delay
.
With one thing like GreenSock, although, we will obtain the impact we wish with relative ease:
import gsap from 'https://cdn.skypack.dev/gsap'
gsap.to('.field', {
rotate: 360,
/**
* A operate based mostly worth, implies that the primary field 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’s!
However how can we do that with out JavaScript?
Nicely, we now have to “hack” our @keyframes
and utterly dispose of animation-delay
. As a substitute, we are going to pad out the @keyframes
with empty area. This comes with numerous quirks, however let’s go forward and construct a brand new keyframe first. This may totally rotate the component twice:
@keyframes spin {
50%, 100% {
rework: rotate(360deg);
}
}
It’s like we’ve minimize the keyframe in half. And now we’ll should double the animation-duration
to get the identical pace. With out utilizing animation-delay
, we might attempt setting animation-direction: reverse
on the second field:
.field {
animation: spin 2s infinite;
}
.field:nth-of-type(2) {
animation-direction: reverse;
}
Nearly.
The rotation is the unsuitable manner spherical. We might use a wrapper component and rotate that, however that might get difficult as there are extra issues to stability. The opposite strategy is to create two keyframes as a substitute of 1:
@keyframes box-one {
50%, 100% {
rework: rotate(360deg);
}
}
@keyframes box-two {
0%, 50% {
rework: rotate(0deg);
}
100% {
rework: rotate(360deg);
}
}
And there we now have it:
This could’ve been lots simpler if we had a method to specify the repeat delay with one thing like this:
/* Hypothetical! */
animation: spin 1s 0s 1s infinite;
Or if the repeated delay matched the preliminary delay, we might presumably have a combinator for it:
/* Hypothetical! */
animation: spin 1s 1s+ infinite;
It will make for an attention-grabbing addition for certain!
So, we want keyframes for all these rings?
Sure, that’s, if we wish a constant delay. And we have to do this based mostly on what we’re going to use because the animation window. All of the rings must have “slinked” and settled earlier than the keyframes repeat.
This could be horrible to jot down out by hand. However this is the reason we now have CSS preprocessors, proper? Nicely, at the least till we get loops and a few additional customized property options on the internet. 😉
In the present day’s weapon of selection can be Stylus. It’s my favourite CSS preprocessor and has been for a while. Behavior means I haven’t moved to Sass. Plus, I like Stylus’s lack of required grammar and adaptability.
Good factor we solely want to jot down this as soon as:
// 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 mostly on the ring index
// index is the ring
$begin = $animation-step * ($ring + 1)
@keyframes slink-{$ring} {
// In right here is the place we have to generate the keyframe steps based mostly on ring depend and window.
0%, {$begin * 1%} {
rework
translate3d(-50%, -50%, var(--origin-z))
translateZ(0)
rotateY(0deg)
}
// Flip with out falling
{($begin + ($animation-window * 0.75)) * 1%} {
rework
translate3d(-50%, -50%, var(--origin-z))
translateZ(0)
rotateY(180deg)
}
// Fall till the cut-off level
{($begin + $animation-window) * 1%}, 100% {
rework
translate3d(-50%, -50%, var(--origin-z))
translateZ(var(--destination-z))
rotateY(180deg)
}
}
Right here’s what these variables imply:
$ring-count
: The variety of rings in our slinky.$animation-window
: That is the proportion of the keyframe that we will slink in. In our instance, we’re saying we wish to slink over50%
of the keyframes. The remaining50%
ought to get used for delays.$animation-step
: That is the calculated stagger for every ring. We are able to use this to calculate the distinctive keyframe percentages for every ring.
Right here’s the way it compiles to CSS, at the least for the primary couple of iterations:
View full code
@keyframes slink-0 {
0%, 4.5% {
rework:
translate3d(-50%, -50%, var(--origin-z))
translateZ(0)
rotateY(0deg);
}
38.25% {
rework:
translate3d(-50%, -50%, var(--origin-z))
translateZ(0)
rotateY(180deg);
}
49.5%, 100% {
rework:
translate3d(-50%, -50%, var(--origin-z))
translateZ(var(--destination-z))
rotateY(180deg);
}
}
@keyframes slink-1 {
0%, 9% {
rework:
translate3d(-50%, -50%, var(--origin-z))
translateZ(0)
rotateY(0deg);
}
42.75% {
rework:
translate3d(-50%, -50%, var(--origin-z))
translateZ(0)
rotateY(180deg);
}
54%, 100% {
rework:
translate3d(-50%, -50%, var(--origin-z))
translateZ(var(--destination-z))
rotateY(180deg);
}
}
The very last thing to do is apply every set of keyframes to every ring. We are able to do that utilizing our markup if we wish by updating it to outline each an --index
and a --name
:
- const RING_COUNT = 10;
.container
.scene
.aircraft(fashion=`--ring-count: ${RING_COUNT}`)
- let rings = 0;
whereas rings < RING_COUNT
.ring(fashion=`--index: ${rings}; --name: slink-${rings};`)
- rings++;
Which supplies us this when compiled:
<div class="container">
<div class="scene">
<div class="aircraft" fashion="--ring-count: 10">
<div class="ring" fashion="--index: 0; --name: slink-0;"></div>
<div class="ring" fashion="--index: 1; --name: slink-1;"></div>
<div class="ring" fashion="--index: 2; --name: slink-2;"></div>
<div class="ring" fashion="--index: 3; --name: slink-3;"></div>
<div class="ring" fashion="--index: 4; --name: slink-4;"></div>
<div class="ring" fashion="--index: 5; --name: slink-5;"></div>
<div class="ring" fashion="--index: 6; --name: slink-6;"></div>
<div class="ring" fashion="--index: 7; --name: slink-7;"></div>
<div class="ring" fashion="--index: 8; --name: slink-8;"></div>
<div class="ring" fashion="--index: 9; --name: slink-9;"></div>
</div>
</div>
</div>
After which our styling could be up to date accordingly:
.ring {
animation: var(--name) var(--speed) each infinite cubic-bezier(0.25, 0, 1, 1);
}
Timing is every part. So we’ve ditched the default animation-timing-function
and we’re utilizing a cubic-bezier
. We’re additionally making use of the --speed
customized property we outlined initially.
Aw yeah. Now we now have a slinking CSS Slinky! Have a play with a few of the variables within the code and see what completely different habits you may yield.
Creating an infinite animation
Now that we now have the toughest half out of the best way, we will make get this to the place the animation repeats infinitely. To do that, we’re going to translate the scene as our Slinky slinks so it seems like it’s slinking again into its authentic place.
.scene {
animation: step-up var(--speed) infinite linear each;
}
@keyframes step-up {
to {
rework: translate3d(-100%, 0, var(--depth));
}
}
Wow, that took little or no effort!
We are able to take away the platform colours from .scene
and .aircraft
to forestall the animation from being too jarring:
Nearly completed! The very last thing to handle is that the stack of rings flips earlier than it slinks once more. That is the place we talked about earlier that using shade would come in useful. Change the variety of rings to an odd quantity, like 11
, and change again to alternating the ring shade:
Increase! We have now a working CSS slinky! It’s configurable, too!
Enjoyable variations
How a few “flip flop” impact? By that, I imply getting the Slink to slink alternate methods. If we add an additional wrapper component to the scene, we might rotate the scene by 180deg
on every slink.
- const RING_COUNT = 11;
.container
.flipper
.scene
.aircraft(fashion=`--ring-count: ${RING_COUNT}`)
- let rings = 0;
whereas rings < RING_COUNT
.ring(fashion=`--index: ${rings}; --name: slink-${rings};`)
- rings++;
So far as animation goes, we will make use of the steps()
timing operate and use twice the --speed
:
.flipper {
animation: flip-flop calc(var(--speed) * 2) infinite steps(1);
top: 100%;
width: 100%;
}
@keyframes flip-flop {
0% {
rework: rotate(0deg);
}
50% {
rework: rotate(180deg);
}
100% {
rework: rotate(360deg);
}
}
Final, however not least, let’s change the best way the .scene
component’s step-up
animation works. It not wants to maneuver on the x-axis.
@keyframes step-up {
0% {
rework: translate3d(-50%, 0, 0);
}
100% {
rework: translate3d(-50%, 0, var(--depth));
}
}
Observe the animation-timing-function
that we use. That use of steps(1)
is what makes it doable.
In order for you one other enjoyable use of steps()
, take a look at this #SpeedyCSSTip!
For an additional contact, we might rotate the entire scene sluggish:
.container {
animation: rotate calc(var(--speed) * 40) infinite linear;
}
@keyframes rotate {
to {
rework:
translate3d(0, 0, 100vmin)
rotateX(-24deg)
rotateY(-32deg)
rotateX(90deg)
translateZ(calc((var(--depth) + var(--stack-height)) * -1))
rotate(360deg);
}
}
I prefer it! After all, styling is subjective… so, I made somewhat app you should utilize configure your Slinky:
And listed here are the “Unique” and “Flip-Flop” variations I took somewhat additional with shadows and theming.
Last demos
That’s it!
That’s at the least one method to make a pure CSS Slinky that’s each 3D and configurable. Positive, you won’t attain for one thing like this on daily basis, nevertheless it brings up attention-grabbing CSS animation strategies. It additionally raises the query of whether or not having a animation-repeat-delay
property in CSS could be helpful. What do you suppose? Do you suppose there could be some good use circumstances for it? I’d like to know.
Make sure to have a play with the code — all of it’s obtainable in this CodePen Collection!