Exploring Unconventional Styling

CSS-only Carousel

Daniel Fuller
Daniel Fuller Founder & Tech Director, Danimate LLC Family man + CSS enthusiast

Motivation

Recently I was playing with the new ::scroll-button() and ::scroll-marker() in Chrome. I was reminded of a CSS experiment I did a few years back (maybe 2020?) where I made a CSS-only carousel.

I frequently look for ways to replace my Javascript with CSS. Is it always a good idea? No. Is it always possible? No. But often I do find some components that have a CSS equivalent where the benefits outweigh the downsides. Now some modern front-end developers will seriously question why replace JS with CSS, and I get that. If you are already writing everything in JS, what’s one more library and a few lines of config? For some projects, a JS library makes sense. Especially considering the constantly changing landscape of browser support.

However, using CSS for some components improves page performance as it’s run on a different thread than JS. It is already optimized for smooth transitions, and 3D transformations are automatically shipped off to the GPU. While the performance improvements may be negligible compared to a JS library, there are many sites that would benefit from quicker load times, even if it’s only a few hundred milliseconds. We’ll explore both the benefits and the downsides of this CSS-only approach.

With some universally supported CSS, some hidden HTML form elements to maintain state, and some basic markup, we can build a nice image carousel without any Javascript.

Here’s a screenshot of what we’ll be building:

Animated Cabin with Day/Night cycle

The Basics

One of the tradeoffs of using CSS instead of JS is the CSS needs to style based on state. The more states there are, the more CSS branches there will need to be. In this case, we will have a sequence of images of which one will be shown at once. So we’ll have as many states as images.

To help maintain clean code, I’m going to use slim and sass for this project. If you haven’t used slim before, it’s basically HTML without closing tags or angle brackets and it uses indentation to determine nesting. And it’s lovely. The CSS equivalent is sass, which is nested CSS without semicolons and curly braces. Each have a few other features we’ll see in this project such as assigning and using variables.

Let’s define a basic page and a few images.

- slides = [ \
  'https://picsum.photos/id/76/800/600', \
  'https://picsum.photos/id/77/800/600', \
  'https://picsum.photos/id/87/800/600', \
  'https://picsum.photos/id/110/800/600', \
  'https://picsum.photos/id/128/800/600', \
  'https://picsum.photos/id/159/800/600', \
  'https://picsum.photos/id/197/800/600', \
  'https://picsum.photos/id/217/800/600', \
]

doctype html
html
  head
    title CSS-only Carousel
    link rel="stylesheet" href="style.css"

  body
    #carousel
      - slides.each do |slide|
        .carousel-slide
          img src=slide

Thanks to Picsum for providing images for this project.

This basic structure will render a blank page, and that’s boring. Let’s loop through our images and render each.

This introduces another feature of slim which is control functions. If you have repetative HTML, you can use various methods to simplify the source code.

Now you can scroll down to see all the images. Feel free to swap out the :id (the first integer parameter in the Picsum url) for some that you’d like to work with. Let’s add some basic layout styles so the images are in a carousel-ish format.

body, html 
  width: 100vw
  height: 100vh

body 
  display: flex 
  justify-content: center
  align-items: center
  margin: 0

#carousel
  width: 100%
  max-width: 800px
  aspect-ratio: 4/3
  overflow: hidden
  position: relative
  padding: 30px 5%
  box-sizing: border-box
  display: flex
  align-items: flex-end 
  justify-content: center
  flex-wrap: wrap

Image gallery without any controls

The basic idea of this carousel is to have all the slides placed in a horizontal row. The row will then shift by 100% to move to the next slide. To facilitate this we’ll need to have a ‘frame’ which defines the viewable area, a container to hold all the slides which can shift left and right, and slides which are all the same size.

Let’s adjust our HTML body to the following:

  body
    #carousel
      #carousel-container
        #carousel-slides
          - slides.each_with_index do |slide, index|
            img src=slide

With a little bit of CSS we’ll be ready to make this much more interesting:

#carousel-container
  height: 100%
  width: 100%
  position: absolute
  top: 0
  left: 0
  transition: left 0.3s ease-in-out

#carousel-slides
  display: flex
  width: max-content 
  height: 100%

If you inspect the DOM and adjust the left attribute of the #carousel-container you will notice the other images scroll into view.

Image gallery with manual animated scrolling

It’s time to add some controls to make it easier to see the other images.

State Management

To allow the user to control which image is currently shown, we need something to hold their chosen state. If we didn’t want the user to control the flow, we could add some CSS animations to control the left attribute and it would cycle through the images. However, we want this to be a choose-your-own-adventure type carousel.

Since only one image will be shown at once, and exactly one image will be shown at once, radio buttons will work best for maintaining state. Let’s add some radio buttons to the #carousel div, and style them so they look like carousel navigation markers.

Here we add a new loop over the slides array to create the radio buttons. It’s important that the radio buttons be adjacent to the #carousel-container so that we can select it easily with CSS (without :has()).

  body
    #carousel
      - slides.each_with_index do |slide, index|
        input type="radio" name="carousel" id="carousel-#{index}" checked=(index == 0)

      #carousel-container
        #carousel-slides
          - slides.each_with_index do |slide, index|
            img src=slide

Now some magic Sass.

input[type="radio"]
  z-index: 1
  appearance: none
  width: 3%
  min-width: 15px
  max-width: 25px
  aspect-ratio: 1/1
  background-color: rgba(255, 255, 255, 0.5)
  cursor: pointer
  border-radius: 50%
  border: 1px solid rgba(0, 0, 0, 0.5)

  &:checked 
    background-color: rgba(255, 255, 255, 1)
    border: 1px solid rgba(0, 0, 0, 1)

    @for $i from 1 through 8
      &:nth-child(#{$i}) ~ #carousel-container
        left: -($i - 1) * 100%

NOTE: This relies on CSS knowing how big the array of images is. There are lots of ways you could do this server-side, or just keep them in sync manually. This is one of the trade-offs of doing this with CSS vs JS. If your gallery is mostly static, CSS is still a great fit.

The code above styles the #carousel-container’s left attribute to match the index of the currently selected radio button. If the second radio button is selected (the slide at index 1), the offset is -100% - thus showing the second slide. It also styles the checked radio button to show it’s selected.

Image gallery with navigation

Next and Prev

As it is, the gallery works just fine. The user can click through the images by tapping on the navigation markers. But a lot of galleries have next and previous buttons so the user doesn’t have to move their mouse every time they want to see a different image. Adding those isn’t too difficult, but it does require some additional markup. Since we’re using radio buttons for state, we can add a label for each radio button, then cleverly hide and show them so the ‘next’ and ‘previous’ buttons always activate the correct radio button.

Adding next and prev button containers to the #carousel leaves our body looking like this:

  body
    #carousel
      - slides.each_with_index do |slide, index|
        input type="radio" name="carousel" id="carousel-#{index}" checked=(index == 0)

      #prev-buttons
        - slides.each_with_index do |slide, index|
          label for="carousel-#{index-1}"

      #next-buttons
        - slides.each_with_index do |slide, index|
          label for="carousel-#{index+1}"

      #carousel-container
        #carousel-slides
          - slides.each_with_index do |slide, index|
            img src=slide

The styles to show the next and prev buttons work like this:

#prev-buttons, #next-buttons
  position: absolute
  right: 0
  top: 0
  bottom: 0
  width: 5%
  background-color: rgba(0, 0, 0, 0.2)
  z-index: 10
  box-shadow: 0px 0 10px 10px rgba(0, 0, 0, 0.2)
  transition: background-color 0.1s ease-in-out, box-shadow 0.1s ease-in-out

  label 
    display: none 
    height: 100%
    cursor: pointer
    background-color: rgba(0, 0, 0, 0)
    transition: background-color 0.1s ease-in-out, box-shadow 0.1s ease-in-out

    &:before 
      content: ""
      width: 50%
      aspect-ratio: 1/1 
      border-top: 2px solid rgba(255, 255, 255, 0.5)
      border-right: 2px solid rgba(255, 255, 255, 0.5)
      border-radius: 5%
      position: absolute
      top: 50%
      left: 30%
      transform: translate(-50%, -50%) rotate(45deg)
      transition: opacity 0.1s ease-in-out
      opacity: 1

    &:hover 
      background-color: rgba(0, 0, 0, 0.1)
      box-shadow: 0px 0 10px 10px rgba(0, 0, 0, .1)

#prev-buttons 
  left: 0
  right: auto

  label:before 
    left: 70%
    transform: translate(-50%, -50%) rotate(-135deg)

We also need to update the :checked state of the radio buttons to show/hide the correct labels depending on which slide is being shown

input[type="radio"]

  ... existing styles ...

  &:checked 
    background-color: rgba(255, 255, 255, 1)
    border: 1px solid rgba(0, 0, 0, 1)

    @for $i from 1 through 8
      &:nth-child(#{$i}) ~ #carousel-container
        left: -($i - 1) * 100%
      &:nth-child(#{$i}) ~ #next-buttons 
        label:nth-child(#{$i})
          display: flex 
      &:nth-child(#{$i}) ~ #prev-buttons 
        label:nth-child(#{$i})
          display: flex 

    &:first-of-type ~ #prev-buttons
      display: none

    &:last-of-type ~ #next-buttons
      display: none

Now that’s looking like a proper carousel.

Carousel with next and prev buttons

UI Cleanup

Often, the image should be fully visible, without any UI overlays. It’s easy to only show the controls when the mouse is hovering #carousel but remember on touch devices, most don’t use :hover state until after a touch is detected. I recommend keeping the clickable elements visually hidden in the DOM so the edges of the images are always tappable, even if not visually indicated.

Hiding the controls until hover can be achieved with:

#prev-buttons, #next-buttons
  background-color: rgba(0, 0, 0, 0)
  box-shadow: 0px 0 10px 10px rgba(0, 0, 0, 0)

  label 
    &:before 
      opacity: 0

#carousel:hover
  #prev-buttons, #next-buttons
    background-color: rgba(0, 0, 0, 0.2)
    box-shadow: 0px 0 10px 10px rgba(0, 0, 0, .2)

    label:before 
      opacity: 1 !important

Gallery animated gif

Conclusion

Now that the gallery is fully functional, there are a lot of improvements that could be made. For instance, you could preload some of the images so the first impression is always clean (<link rel="preload" href="https://picsum.photos/id/76/800/600" as="image">) but remember that will slow down the initial paint of your site.

You could also render labels with small thumbnails of the images next to the radio buttons, then hide the radio button inputs and rely on the user clicking the thumbnail/label to select the radio button.

You could move the next/prev button containers to the upper right corner. Or to the lower corners, so all the controls are right along the bottom of the carousel.

With a little effort, you could add a few classes that change the whole theme of the carousel - .controls-bottom, .vertical-controls or .thumbnail-controls so changing the user experience is as easy as adding a class. I’ll leave that up to you.

A little JS could make this an auto-play gallery by setting an interval to select the next radio button every 5 seconds and removing the manual controls.

CSS will obviously never replace JS, but I feel we should use the languages for their intended purpose. CSS should be mainly for presentation, Javascript for logic that must be handled in front of the user, such as form assistance. Though, CSS can get you a long ways with form validation, but that’s a story for another day.

You can view the Full Carousel Example Page or you can check out the CSS-only Carousel repo on Github.