How to Make a CSS Slinky in 3D | Style-Tricks

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.

Animated Gif of a Slinky toy going down stairs.
A slinking Slinky

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 utilizing perspective 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 over 50% of the keyframes. The remaining 50% 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!



Add a Comment

Your email address will not be published. Required fields are marked *