CSS Infinite 3D Sliders | CSS-Tricks

In this series, we’ve created image sliders with nothing but HTML and CSS. The idea is that we can use the same markup but different CSS to get wildly different results no matter how many images we throw in. We started with a circular slider that rotates infinitely, like a fidget spinner that holds images. Then we made one that scrolls through a stack of images.

This time we dive into the third dimension. It’s going to look tough at first, but much of the code we’ll be looking at is exactly what we used in the first two articles in this series, with some modifications. So if you’re just getting into the series, I’d suggest checking out the others to get context on the concepts we’re using here.

This is what we aim for:

At first glance, it looks like we have a rotating cube with four images. But in reality we are dealing with six images in total. Here’s the slider from another angle:

Now that we have a good idea of ​​how the images are arranged, let’s dissect the code to see how we get there.

The basic setup

Same HTML as the rest of the sliders we’ve used for the other sliders:

<div class="gallery">
  <img src="" alt="">
  <img src="" alt="">
  <img src="" alt="">
  <img src="" alt="">
  <img src="" alt="">

And once again we use CSS Grid to place the images in a stack on top of each other:

.gallery {
  display: grid;
.gallery > img {
  grid-area: 1 / 1;
  width: 160px;
  aspect-ratio: 1;
  object-fit: cover;

The animation

The logic for this slider is very similar to the circular slider from the first article. In fact, if you check the video above again, you can see that the images are placed in a way that creates a polygon. After a full rotation, it returns to the first image.

We trusted CSS transform-origin and animation-delay properties of the first slider. The same animation is applied to all the image elements that rotate around the same point. Then, using different delays, we place all the images correctly around a large circle.

The implementation will be slightly different for our 3D slider. Using transform-origin won’t work here because we’re working in 3D, so we’ll use transform instead of placing all the images correctly, then rotate the container.

We reach for Sass again so we can loop through the number of images and apply our transformations:

@for $i from 1 to ($n + 1) {
  .gallery > img:nth-child(#{$i}) {
       rotate(#{360*($i - 1) / $n}deg) /* 1 */
       translateY(50% / math.tan(180deg / $n)) /* 2 */ 
       rotateX(90deg); /* 3 */

You might be wondering why we’re jumping straight into Sass. We started with a fixed number of images using vanilla CSS in the other articles before generalizing the code with Sass to accommodate any number (N) of images. Well, I think you get the idea now and we can cut out all that discovery work to get to the real implementation.

That transform the property takes three values, which I’ve illustrated here:

Shows the three phases of the image slider layout.

We first rotate all the images over each other. The rotation angle depends on the number of images. To N images, we have an increase corresponding to 360deg/N. We saw translate all the images in the same amount in a way that makes their centers meet on the sides.

Displays the stack of images arranged flat in a circle with a red line running through the center of the images.

There’s some boring geometry that helps explain how all this works, but the distance is equal to 50%/tan(180deg/N). We dealt with a similar equation when we made the circular slider ( transform-origin: 50% 50%/sin(180deg/N) ).

Finally, we rotate the images around the x-axis with 90deg to get the arrangement we want. Here’s a video illustrating what the final rotation does:

Now we just need to rotate the entire container to create our infinite slider.

.gallery {
  transform-style: preserve-3d;
  --_t: perspective(280px) rotateX(-90deg);
  animation: r 12s cubic-bezier(.5, -0.2, .5, 1.2) infinite;
@keyframes r {
  0%, 3% {transform: var(--_t) rotate(0deg); }
  @for $i from 1 to $n {
    #{($i/$n)*100 - 2}%, 
    #{($i/$n)*100 + 3}% {
      transform: var(--_t) rotate(#{($i / $n) * -360}deg);
  98%, 100% { transform: var(--_t) rotate(-360deg); }

That code can be hard to understand, so let’s actually go back for a moment and revisit the animation we made for the circular slider. This is what we wrote in the first article:

.gallery {
  animation: m 12s cubic-bezier(.5, -0.2, .5, 1.2) infinite;
@keyframes m {
  0%, 3% { transform: rotate(0); }
  @for $i from 1 to $n {
    #{($i / $n) * 100 - 2}%,
    #{($i / $n) * 100 + 3}% { 
      transform: rotate(#{($i / $n) * -360}deg);
  98%, 100% { transform: rotate(-360deg); }

The keyframes are almost identical. We have the same percentages, the same loop and the same rotation.

Why are both the same? Because their logic is the same. In both cases the images are arranged around a circular shape and we need to rotate the whole thing to display each image. This is how I was able to copy the keyframes from the circular slider and use the same code for our 3D slider. The only difference is that we have to rotate the container by -90deg along the x-axis to see the images since we have already rotated them along 90deg on the same axis. Then we add a touch of perspective to get the 3D effect.

That is it! Our slider is finished. Here is the full demo again. All you have to do is add as many images as you want and update a variable to get it going.

Vertical 3D shooter

Since we are playing in the 3D space, why not make a vertical version of the previous slider? The last one rotates along the z-axis, but we can also move along the x-axis if we want.

If you compare the code for both versions of this slider, you might not immediately notice the difference because it’s only one character! I replaced rotate() with rotateX() inside the keyframes and the image transform. That is it!

It should be noted that rotate() corresponds to rotateZ()then by changing the axis from Z to X we transform the slider from the horizontal version to the vertical one.

Cube shooter

We can’t talk about 3D in CSS without talking about cubes. And yes, that means we have to make another version of the slider.

The idea behind this version of the slider is to create an actual cube shape with the images and rotate the full thing around the other axis. Since it is a die, we are dealing with six faces. We use six images, one for each side of the cube. So no Sass but back to vanilla CSS.

That animation is a bit overwhelming, isn’t it? Where do you even start?

We have six faces, so we need to perform at least six rotations so that each image gets a turn. Well, actually we need five rotations – the last one brings us back to the first image face. If you grab a Rubik’s Cube – or any other cube-shaped object like dice – and rotate it with your hand, you’ll have a good idea of ​​what we’re doing.

.gallery {
  --s: 250px; /* the size */

  transform-style: preserve-3d;
  --_p: perspective(calc(2.5*var(--s)));
  animation: r 9s infinite cubic-bezier(.5, -0.5, .5, 1.5);

@keyframes r {
  0%, 3%   { transform: var(--_p); }
  14%, 19% { transform: var(--_p) rotateX(90deg); }
  31%, 36% { transform: var(--_p) rotateX(90deg) rotateZ(90deg); }
  47%, 52% { transform: var(--_p) rotateX(90deg) rotateZ(90deg) rotateY(-90deg); }
  64%, 69% { transform: var(--_p) rotateX(90deg) rotateZ(90deg) rotateY(-90deg) rotateX(90deg); }
  81%, 86% { transform: var(--_p) rotateX(90deg) rotateZ(90deg) rotateY(-90deg) rotateX(90deg) rotateZ(90deg); }
  97%, 100%{ transform: var(--_p) rotateX(90deg) rotateZ(90deg) rotateY(-90deg) rotateX(90deg) rotateZ(90deg) rotateY(-90deg); }

That transform property starts with zero rotations and in each state we add a new rotation on a particular axis until we reach six rotations. So we are back to the first picture.

Let’s not forget the placement of our images. Each one is applied to a face of the cube using transform:

.gallery img {
  grid-area: 1 / 1;
  width: var(--s);
  aspect-ratio: 1;
  object-fit: cover;
  transform: var(--_t,) translateZ(calc(var(--s) / 2));
.gallery img:nth-child(2) { --_t: rotateX(-90deg); }
.gallery img:nth-child(3) { --_t: rotateY( 90deg) rotate(-90deg); }
.gallery img:nth-child(4) { --_t: rotateX(180deg) rotate( 90deg); }
.gallery img:nth-child(5) { --_t: rotateX( 90deg) rotate( 90deg); }
.gallery img:nth-child(6) { --_t: rotateY(-90deg); }

You’re probably thinking there’s some weird complex logic behind the values ​​I’m using there, right? So no. All I did was open DevTools and play around with different rotation values ​​for each image until I got it right. It might sound silly, but hey, it works – especially since we have a fixed number of images and we’re not looking for something that supports N Pictures.

Actually, forget the values ​​I’m using and try doing the placement on your own as an exercise. Start with all the images stacked on top of each other, open DevTools, and go! You’ll probably end up with a different code, and that’s totally fine. There can be different ways to place the images.

What is the trick with the comma inside var()? Is it a typo?

It’s not a typo, so don’t remove it! If you remove it, you will notice that it affects the position of the first image. You can see that in my code I defined --_t for all images except the first one because I only need a translation for it. This comma causes the variable to fall back to a zero value. Without the comma, we won’t have a reserve and the entire value will be invalid.

From the specification:

Note: I.e. var(--a,) is a valid function that states that if --a custom property is invalid or missing, the var()` should be replaced with nothing.

Random dice shooter

A little bit of randomness can be a nice enhancement to this kind of animation. So instead of rotating the die in sequential order, we can roll the dice, so to speak, and let the die roll as it will.

Cool right? I don’t know about you, but I like this version better! It’s more interesting and the transitions are satisfying to watch. And guess what? You can play with the values ​​to create your own random dice shooter!

The logic is actually not random at all – it just seems that way. You define one transform on each keyframe allowing you to show one face and… yes, that’s it! You can choose any order you want.

@keyframes r {
  0%, 3%   { transform: var(--_p) rotate3d( 0, 0, 0,  0deg); }
  14%,19%  { transform: var(--_p) rotate3d(-1, 1, 0,180deg); }
  31%,36%  { transform: var(--_p) rotate3d( 0,-1, 0, 90deg); }
  47%,52%  { transform: var(--_p) rotate3d( 1, 0, 0, 90deg); }
  64%,69%  { transform: var(--_p) rotate3d( 1, 0, 0,-90deg); }
  81%,86%  { transform: var(--_p) rotate3d( 0, 1, 0, 90deg); }
  97%,100% { transform: var(--_p) rotate3d( 0, 0, 0,  0deg); }

I use rotate3d() this time, but I still trust DevTools to find the values ​​that feel “right” to me. Don’t try to find a relationship between keyframes because there simply isn’t one. I define separate transformations and then see the “random” result. Make sure the first image is the first and last image respectively, and display a different image on each of the other frames.

You are not required to use one rotate3d() transform like i did. You can also chain different rotations as we did in the previous example. Play around and see what you can come up with! I’m waiting for you to share your version with me in the comments!


I hope you enjoyed this little series. We built some fun (and hilarious) sliders while learning a lot about all sorts of CSS concepts along the way – from grid placement and stacking order to animation delays and transforms. We even got to play with a touch of Sass to loop through a series of elements.

And we did it all with the exact same HTML for every single slider we made. How cool is that? CSS is very powerful and able to accomplish so much without the help of JavaScript.


Leave a Reply