Many websites have sliders that show additional content to what fits on the screen horizontally. From images to product lists to videos, you see this type of component a lot. Often times sliders animate strangely and do not work with touch or other native input methods. This results in bad accessibility and poor performance. Because there is no actual HTML element for a content slider, developers have to figure out how to build them on their own.

In this article you will learn how to build an accessible, performant, responsive, and cross-device content slider using native HTML, simple CSS, and some Vue.js to connect the dots. Its base is simple and can be expanded upon later. It has great bones and will serve you well going forward.

Below are some examples of the types of slider this article will look at.

Examples of media sliders
Examples of media sliders

Progressive enhancement

The key feature of this slider is that it uses progressive enhancement as a core feature. Progressive enhancement means that you have something that works in a simple way on all browsers. Based on the features of the browser/device, extra functionality is added progressively. Think about having JavaScript, a touch input or lazy loading abilities. The aim is to use as many browser native HTML and CSS as possible. If there is no JavaScript, the slider works fine. When JavaScript is added, it works better. This is good for SEO, and there will be no cumulative layout shift when the JavaScript is loaded.

Using as many native elements as possible greatly improves the performance and accessibility as well. For example, why slide a div around with a left position or a translateX when a native scroll with overflow: scroll is highly optimized by the browser already? Using native scrolling performs a lot better than doing it artificially. On top, you also get the accessibility benefits of a natively scrolling element. If you focus on a DOM node inside the scroller it will automatically scroll itself to the focused element, for example. Keyboard users will love you for this! If you add a skip link before the slider itself you have a full WGAC AA rating, if the contrast ratio in the design is properly set up, you may get a WGAC AAA rating.

The fact that progressive enhancement also considers JavaScript as a feature is a great idea in theory. However, if you are using Vue.js in SPA mode, there is nothing to be seen on the screen without JavaScript. If you have SSR (Wordpress PHP templates, Adobe AEM Java HTML generation or Nuxt/Next) HTML is rendered and gets hydrated by JavaScript. That JavaScript has to be downloaded, parsed and applied.

How does your page load while hydrating? That’s where you either see a broken website or a white page. That leads to a poor user experience. When you build semantic HTML and proper CSS in a progressively enhanced way, the user has a great experience without JavaScript (while it still loads) on a slow device.

I always say: the user doesn't have JavaScript until all bundles have been downloaded, parsed and executed.

The examples in this article are hosted on Codesandbox.io. CodeSandbox runs Vue in SPA mode and it requires JavaScript to run. This means you can't see the example without JavaScript.

Native HTML elements

The slider is a combination of a div element which has overflow: scroll and an input with type range. The range input replaces the native scrollbar of the scrolling div to give the component more flexibility in usage and design.

Dragging the range input scrolls the div. Scrolling the div moves the range input to a value of how much the div has been scrolled. The connection between these two native HTML elements is controlled with JavaScript. Without JavaScript, the range input is not used and the div can be scrolled manually. With JavaScript, the native scroll tools from HTML are disabled by setting scrollbar-width: none; and some ::-webkit-scrollbar specific CSS.

The range input functions as the progress indicator and scroll instrument for the scrollable div. There is a bit of specific cross-browser CSS to make the range input look the same across browsers.

This SCSS code has a bunch of variables and mixins to simplify how it looks. In essence, it just styles the range input the same in all browsers. Notice the CSS variable --thumb-width which defaults to 30%. You can set the width of the dragging head of the range input here from the JavaScript. The width of the dragging handle represents how far you can drag the scrollable div it controls, just like a real scroll bar.

$dark: #111;
$accent: #d62b31;

$thumb-radius: 0;
$thumb-height: 4px;
$thumb-width: 30%;

$track-width: 100%;
$track-height: 4px;
$track-border-width: 1px;
$track-border-color: $light;

$track-radius: 0;
$contrast: 5%;

$ie-bottom-track-color: darken($dark, $contrast);

@mixin track {
  cursor: default;
  height: $track-height;
  transition: all 0.2s ease;
  width: $track-width;
}

@mixin thumb {
  background: $dark;
  box-sizing: border-box;
  cursor: default;
  height: $thumb-height;
}

[type="range"] {
  -webkit-appearance: none;
  background: transparent;
  width: $track-width;
  display: block;
  padding: 10px 0;

  &::-moz-focus-outer {
    border: 0;
  }

  &:hover {
    &::-webkit-slider-thumb {
      transform: scale3D(1, 1.3, 1);
    }

    &::-moz-range-thumb {
      transform: scale3D(1, 1.3, 1);
    }

    &::-ms-thumb {
      transform: scale3D(1, 1.3, 1);
    }
  }

  &:focus {
    outline: 0;

    &::-webkit-slider-runnable-track {
      background: $accent;
    }

    &::-ms-fill-lower {
      background: $accent;
    }

    &::-ms-fill-upper {
      background: $accent;
    }
  }

  &::-webkit-slider-runnable-track {
    @include track;
    background: rgba($dark, 0.1);
  }

  &::-webkit-slider-thumb {
    @include thumb;
    -webkit-appearance: none;
    margin-top: 0;
    transition: all 0.3s ease;
    width: var(--thumb-width, 30%);
  }

  &::-moz-range-track {
    @include track;
    background: rgba($dark, 0.1);
    height: $track-height / 2;
  }

  &::-moz-range-thumb {
    @include thumb;
    width: var(--thumb-width, 30%);
  }

  &::-ms-track {
    @include track;
    background: transparent;
    border-color: transparent;
    border-width: ($thumb-height / 2) 0;
    color: transparent;
  }

  &::-ms-fill-lower {
    background: $ie-bottom-track-color;
    border: $track-border-width solid $track-border-color;
    border-radius: ($track-radius * 2);
  }

  &::-ms-fill-upper {
    background: rgba($dark, 0.1);
    border: $track-border-width solid $track-border-color;
    border-radius: ($track-radius * 2);
  }

  &::-ms-thumb {
    @include thumb;
    margin-top: $track-height / 4;
  }
}

Connect the range input to the scrolling element

See below the simplest version of the roller component. We will enhance this code as the MediaJam continues. The code features a slot to add any sort of content as li tags. This is a little opinionated so feel free to change this. Just make sure the ref="scrollable" remains on that parent HTML tag.

In the version below, we are listening to the range input value to calculate how far to scroll the scrollable div. When the component mounts, it calculates the width of the scrollable area, how far it can scroll, and how wide the scroll head on the range input has to become in percentage. Next to that, it also listens to the resize event of the browser to recalculate all the values based on the new screen width. When the component is removed, we stop listening to the resize event.

The Vue @input event fires the updateScroll function with all event data needed. Based on the calculated values in updateScroll the scrollable div element is scrolled to its final position by firing this.$refs.scrollable.scrollTo(this.scrollValue, 0). The scrollValue variable is calculated by the updateScroll function. See code below.

<template>
  <div class="roller">
    <div class="roller-content">
      <ul ref="scrollable">
        <slot />
      </ul>
    </div>
    <div v-if="navWidth < 100" class="roller-nav">
      <input
        type="range"
        min="0"
        max="100"
        step="1"
        aria-hidden="true"
        :value="rangeValue"
        :style="cssVars"
        @input="updateScroll($event)"
      />
    </div>
  </div>
</template>

<script>
export default {
  name: "Roller",
  data() {
    return {
      rangeValue: 0,
      scrollValue: 0,
      totalWidth: 0,
      navWidth: 0,
    };
  },
  computed: {
    cssVars() {
      return {
        "--thumb-width": `${this.navWidth}%`,
      };
    },
  },
  mounted() {
    this.calculate();
    window.addEventListener("resize", this.calculate);
  },
  beforeDestroy() {
    window.removeEventListener("resize", this.calculate);
  },
  methods: {
    calculate() {
      const { scrollWidth, clientWidth } = this.$refs.scrollable;
      this.totalWidth = scrollWidth - clientWidth;
      this.navWidth = 100 / (scrollWidth / clientWidth);
    },
    updateScroll(e) {
      this.rangeValue = e.target.value;
      this.scrollValue = (this.totalWidth / 100) * this.rangeValue;
      this.$refs.scrollable.scrollTo(this.scrollValue, 0);
    },
  },
};
</script>

Move the range input by dragging the scrollable element

To complete the communication between the range input and the scrollable div we also need to make sure that dragging the scrollable element updates the range input. For this we need to bind a bunch of touch related events to the scrollable element of the component.

On the scrollable div a couple extra event handlers have to be added to enable touch behaviour (also on desktop when dragging with the mouse). We also have to add a scroll handler to figure out where to move the range input to.

  • `@scroll="updateRange"`: On scroll of the `div` element we set the value of the range `input`.
  • `@mousedown="down"`: On mouse or finger down, to set the start position and the `isDown` properties.
  • `@mouseleave="leave"` and `@mouseup="leave"`: `isDown` and `isRolling` properties are set to false.
  • `@mousemove.prevent="move($event)"`: if `isDown` is set, the scrolling position for the range `input` is calculated and the `isRolling` property is set to `true`.

Have a look at the code to understand the calculations that are made inside these functions.

<template>
  <div class="roller">
    <div class="roller-content">
      <ul
        ref="scrollable"
        :class="{ 'is-rolling': isRolling }"
        @scroll="updateRange"
        @mousedown="down"
        @mouseleave="leave"
        @mouseup="leave"
        @mousemove.prevent="move($event)"
      >
        <slot />
      </ul>
    </div>
    <div v-if="navWidth < 100" class="roller-nav">
      <input
        ref="input"
        type="range"
        min="0"
        max="100"
        step="1"
        :value="rangeValue"
        :style="cssVars"
        aria-hidden="true"
        @input="updateScroll($event)"
      />
    </div>
  </div>
</template>

<script>
export default {
  name: "Roller",
  data() {
    return {
      rangeValue: 0,
      scrollValue: 0,
      totalWidth: 0,
      isDown: false,
      startX: 0,
      scrollLeft: 0,
      navWidth: 0,
      isRolling: false,
    };
  },
  computed: {
    cssVars() {
      return {
        "--thumb-width": `${this.navWidth}%`,
      };
    },
  },
  mounted() {
    this.calculate();
    window.addEventListener("resize", this.calculate);
  },
  beforeDestroy() {
    window.removeEventListener("resize", this.calculate);
  },
  methods: {
    calculate() {
      const { scrollWidth, clientWidth } = this.$refs.scrollable;
      this.totalWidth = scrollWidth - clientWidth;
      this.navWidth = 100 / (scrollWidth / clientWidth);
    },
    down(e) {
      const { offsetLeft, scrollLeft } = this.$refs.scrollable;
      this.isDown = true;
      this.startX = e.pageX - offsetLeft;
      this.scrollLeft = scrollLeft;
    },
    leave() {
      this.isRolling = false;
      this.isDown = false;
    },
    move(e) {
      if (!this.isDown) {
        return;
      }

      const x = e.pageX - this.$refs.scrollable.offsetLeft;
      const walk = x - this.startX;

      if (Math.abs(walk) > 40) {
        this.isRolling = true;
      }

      this.$refs.scrollable.scrollLeft = this.scrollLeft - walk;
    },
    updateScroll(e) {
      this.rangeValue = e.target.value;
      this.scrollValue = (this.totalWidth / 100) * this.rangeValue;
      this.$refs.scrollable.scrollTo(this.scrollValue, 0);
    },
    updateRange() {
      const scroll = this.$refs.scrollable.scrollLeft;
      this.rangeValue = (scroll / this.totalWidth) * 100;
    },
  },
};
</script>

Additional accessibility

Just one more thing should be added to make the slider properly work with accessibility. Lists tend to be hard to skip as a keyboard user. Imagine having twenty items that are not interesting for you as a user, and you have to "tab" through them before being able to go to the next component on the page. For these situations we add "skip links".

A skip link looks like this and is only visible when you focus it.

<a href="#unique-skip-id" class="skip-link is-sr-only is-sr-only-focusable">SKIP CONTENT</a>
<ul>
    <li>long list of content items...</li>
</ul>
<a id="unique-skip-id"></a>

The is-sr-only class makes sure only screen readers see this link. See the code sandbox for more info.

Concluding

As it turns out, creating a content slider that is performant, responsive, progressively enhanced, and good for accessibility is not as intimidating as it looks, you just have to put the right building blocks together and connect them up with some JavaScript.

See the code here: https://codesandbox.io/s/crazy-hellman-qf7h8