Pure CSS background image slides

Sliding photos in the background with nice transitions can be done with pure CSS, without sacrificing anything.

A hand holding a positive of a photo of a mountain.
Photo by Nathan Anderson / Unsplash

I needed to show a list of images as background of header and cross-fading each other in time, like a slide show. My requirements were:

  • Images should be used as background images. Because images are only for visual enhancement, not a real data.
💡
Nowadays most people ignore semantic HTML, but I still care it. And that means, if you show an image just for adding some style to your page and if it doesn't add any meaning to your document, than you should avoid using img or picture tags.
  • Images should be lazy loaded. I should avoid fetching all of the slide images when page loaded.
  • Nice to have: It would be great to be able to define different formats and sizes for each slide to pick best for the client. Like we do with <source>.

There are many ways to make an image slider with or without getting help from CSS. But it's not common -or at least I couldn't see many examples- to have sliding those don't need an HTML markup. But after I played some with CSS animations, I realized that it's considerably easy to do that, except with some tricks (as always).

Let's start with basics. For the simplicity, we'll just use body element to show rotating full-screen background images with a transition. We can fill our background with a photo like below:

body {
  background-size: cover;
  background-image: url('https://picsum.photos/900/400/');
}

If we convert this definition to a basic keyframe animation, it will still work.

body {
  background-size: cover;
  animation-name: slide;
  animation-fill-mode: both;
}

@keyframes slide {
  0%,100% {
    background-image: url('https://picsum.photos/900/400/');
  }
}

Here we define an animation that just show an background image and finishes immediately. animation-fill-mode: both points that when animation ends, it'll stay its latest position.

So now, what happens if we add one more slide to our animation and set sime time? Let's try:

body {
  background-size: cover;
  animation-name: slide;
  animation-fill-mode: both;
  animation-duration: 10s;
  animation-iteration-count: infinite;
}

@keyframes slide {
  0%,49% {
    background-image: url('https://picsum.photos/900/400/?random=1');
  }
  50%,100% {
    background-image: url('https://picsum.photos/900/400/?random=2');
  }
}

Now I set a duration for the animation as 10 seconds. To loop the animation we set animation-iteration-count: infinite. And added one more image to the slides and sliced the total range by setting percentages, to show first image in first half, and second image in the second half.

Now we already have 2 image background slides. That was easy, isn't it? We even covered our 2nd requirement, which was lazy loading. If you open the demo and check your developer tools network tab, you'll notice that second image will not be loaded until it's being showed. Cool!

But that also causes a problem, right? Did you notice the flick? Since we load the image just when it's needed to be shown, and it takes some time to fetch the image, we see a white screen in between. How can we solve this? We somehown need to fetch the image before showing it. Luckily we are able to use multiple background images with CSS. And since we use background-size: cover , even if we use for multiple images, only one of them will be shown. I think we can use this. Let's check:

body {
  background-size: cover;
  animation-name: slide;
  animation-fill-mode: both;
  animation-duration: 15s;
  animation-iteration-count: infinite;
}

@keyframes slide {
  0%,33% {
    background-image: url('https://picsum.photos/900/400/?random=1'), url('https://picsum.photos/900/400/?random=2');
  }
  34%,66% {
    background-image: url('https://picsum.photos/900/400/?random=2'), url('https://picsum.photos/900/400/?random=3');
  }
  67%,100% {
    background-image: url('https://picsum.photos/900/400/?random=3');
  }
}

Did you see it? It works! Now we fetch first 2 images immediately, and 3rd image when we show the 2. slide. No flicker! If you don't want to fetch 2 images at the same time, you can even optimize more by adding another semi-step (like at 10%) to just fetch the 2. image. Like:

...

@keyframes slide {
  0%,10% {
    background-image: url('https://picsum.photos/900/400/?random=1');
  }
  11%,33% {
    background-image: url('https://picsum.photos/900/400/?random=1'), url('https://picsum.photos/900/400/?random=2');
  }
  34%,66% {
    background-image: url('https://picsum.photos/900/400/?random=2'), url('https://picsum.photos/900/400/?random=3');
  }
...

OK, what's next? We need some transition, right? You maybe also be ok with that, but let's check how can add a cross-fading transition between the slides.

You know what? It's actually already there but you can't see it. Let me explain:

In our latest example we say we want an animation that will take 15 seconds. And we defined 3 slides in keyframes by pointing their positions with percentages. Percentages are like video slider positions. We define "keyframes" of our animation, and browsers "animate" the values "between" them. So we say "From 0% to 33%, background image will be this", and then "from 34% to 66% background image will be this". But what happens between %33 and %34? You guess right; animation happens there. Because that gap is where browser does the animation. But since total animation duration is 15 seconds, that means 1% takes only 150ms, which is so quick to notice. If we make those gaps bigger, then we'll able to notice the transition. If we make gaps 6%, transitions will be 900ms, nearly 1 second.

body {
  background-size: cover;
  animation-name: slide;
  animation-fill-mode: both;
  animation-duration: 15s;
  animation-iteration-count: infinite;
}

@keyframes slide {
  0%,28% {
    background-image: url('https://picsum.photos/900/400/?random=1'), url('https://picsum.photos/900/400/?random=2');
  }
  34%,59% {
    background-image: url('https://picsum.photos/900/400/?random=2'), url('https://picsum.photos/900/400/?random=3');
  }
  67%,94% {
    background-image: url('https://picsum.photos/900/400/?random=3');
  },
  100% {
    /* Transition to first slide */
    background-image: url('https://picsum.photos/900/400/?random=1')
  }
}

Nice! Now we have a cool cross-fade transition. If you want to try different transition effects (blur would be nice too), backdrop-filter will be your friend.

Bonus: Auto-select best image for the device

No HTML markup? Check! Lazy loading? Check! Why do we stop here? Let's also check how can we provide best possible image for our visitor's device.

What I mean? There are multiple very optimized image formats those browsers support: JPEG, AVIF, WEBP... AVIF and WEBP formats can reduce our total file size dramatically. But not all browsers support them. We need to check browser support and show the best option it can handle.

💡
Of course file type is not the only concern about providing the best image. Image resolution and density is other points that we need to consider. But those are already easily be covered with CSS @media queries. So I will not enter that topic in this article.

Luckily, image-set is already winking to us. image-set is the <source> of CSS. Its syntax is very easy:

body {
  background-image: image-set(
    url('https://placehold.co/600x400/webp?text=WEBP') type('image/webp'),
    url('https://placehold.co/600x400/jpg?text=JPEG') type('image/jpeg')
  );
}

Don't miss that order is important. The best option for you should be placed first and last one should be the fallback. Ah, speaking of fallback, we must not forget browsers that do not support image-set. Just place your fallback image before image-set definition like:

body {
  background-image: url('https://placehold.co/600x400/jpg?text=JPEG');
  background-image: image-set(
    url('https://placehold.co/600x400/webp?text=WEBP') type('image/webp'),
    url('https://placehold.co/600x400/jpg?text=JPEG') type('image/jpeg')
  );
}

Now, we are able to define our images with multiple types. Latest code will be a bit crowded. Something like:

@keyframes slide-show {
    0%,17% {
        background-image: url('slide1.jpg'), url('slide2.jpg') ;
        background-image: image-set(
            url('slide1.avif') type('image/avif'),
            url('slide1.webp') type('image/webp'),
            url('slide1.jpg') type('image/jpeg')
        ), image-set(
            url('slide2.avif') type('image/avif'),
            url('slide2.webp') type('image/webp'),
            url('slide2.jpg') type('image/jpeg')
        );
    }
    20%,37% {
        background-image: url('slide2.jpg'), url('slide3.jpg');
        background-image: image-set(
            url('slide2.avif') type('image/avif'),
            url('slide2.webp') type('image/webp'),
            url('slide2.jpg') type('image/jpeg')
        ), image-set(
            url('slide3.avif') type('image/avif'),
            url('slide3.webp') type('image/webp'),
            url('slide3.jpg') type('image/jpeg')
        );        
    }
...
💡
Are you looking for a tool to optimize and convert your images? Check https://squoosh.app

It looks crowded but I loved the result. It's fast, it's good and it's cheap. But I also see this as a start. Modern CSS provides unlimited opportunities to us and all of them are waiting us to discover them.

I hope this tutorial helped you. What do you think? Is it really that good, or am I just overreacting? Do you have more points to improve it? Please share your thoughts!

Me on Mastodon: https://synaps.space/@murat