Building an accessible image comparison web component

Written by Paul Hebert on

For a recent article we wanted to allow readers to visually compare two images. There are lots of existing tools, but all of the ones we found had drawbacks that made us not want to use them on the blog:

  • Some were inaccessible and impossible to control using a keyboard or screen reader.
  • Some relied on other tools, like React or jQuery.
  • Some loaded lots of JavaScript which could slow down our page loads.

It’s important that our blog posts are accessible and load quickly, so we decided to roll up our sleeves and write our own solution.

The Finished Product


A friendly illustrated cloud character with eyes on a red-pink background.
A friendly illustrated cloud character with eyes surrounded by a variety of icons (including check marks, stars, code brackets, buttons, hearts, a checkbox, and many more) on a blue background.

The finished product is an open source web component called Image Compare. By leveraging native browser controls I was able to make it accessible, tiny (1.5kb gzipped and minified), and dependency-free.

Using Image Compare requires loading a script, and then passing in a couple images:

<script src="https://unpkg.com/@cloudfour/image-compare/dist/index.min.js"></script>

<image-compare>
  <img slot="image-1" alt="Alt Text" src="path/to/image.jpg" />
  <img slot="image-2" alt="Alt text" src="path/to/image.jpg" />
</image-compare>

For more information you can view the docs, install it from npm, or view the source code on GitHub.

The challenge

Before I started, I needed to make sure I understood what I was building. My solution needed to do the following:

  • Display two images layered on top of each other
  • Allow viewers to drag a slider handle to control the visibility of the two images
  • Ensure anyone can use the slider, whether they’re using a mouse, a touch screen, their keyboard, or an assistive technology like a screen reader
  • Keep the solution small, quick loading, and high performing

Breaking it down

To tackle this, I broke it down into a series of smaller steps I could knock out one by one.

Layering the images

The HTML code for layering the images was pretty straightforward:

<div class="image-compare">
  <img class="image-1" src="path/to/image" alt="alt text" />
  <img class="image-2" src="path/to/image" alt="alt text" />
</div>

By absolute-positioning the second image I could layer it on top of the first image:

/* Create a positioning context for the images */
.image-compare {
  position: relative;
}

.image-2 {
  display: block;
  position: absolute;
  top: 0;
}

Now the second image is layered on top of the first image, but the first image is completely blocked. In order to obscure a portion of the second image we can use a css clip-path.

.image-2 {
  clip-path: polygon(50% 0, 100% 0, 100% 100%, 50% 100%);
}

This clips the second image so only the right half is showing. However, we need to be able to dynamically update the percentage that is shown. Switching the 50% to a custom property will make it easier to update the value with JavaScript.

.image-2 {
  --exposure: 50%;

  clip-path: polygon(
    var(--exposure) 0,
    100% 0,
    100% 100%,
    var(--exposure) 100%
  );
}

Adding a slider

Next up we need to add a way for users to adjust the clipping path on the top image. Luckily, browsers have a built-in control to pick a number between two values: range inputs!

<div class="image-compare">
  <img class="image-1" src="path/to/image" alt="alt text" />
  <img class="image-2" src="path/to/image" alt="alt text" />

  <label class="image-compare-label">
    Select what percentage of the bottom image to show
    <input type="range" min="0" max="100" class="image-compare-input" />
  </label>
</div>

Now that we’ve added a slider, we need to hook it up to control our clip-path. Since we’re using a native browser control to update a custom property, our JavaScript is pretty concise:

const clippedImage = document.querySelector(".image-2");
const clippingSlider = document.querySelector(".image-compare-input");

// Listen for the input being dragged
clippingSlider.addEventListener("input", (event) => {
  // Grab the input's value
  const newValue = `${event.target.value}%`;
  // Use it to set our custom property
  clippedImage.style.setProperty("--exposure", newValue);
});

With that hooked up we can control our clipping path using the range input!

Andrey Gurtovoy mentioned in the comments on this article that using requestAnimationFrame would be better for performance. We can use requestAnimationFrame to allow the browser to decide when to repaint like so:

const clippedImage = document.querySelector(".image-2");
const clippingSlider = document.querySelector(".image-compare-input");

// Store an animation frame so we can keep track of scheduled
// repaints and cancel old repaints that haven't happened yet
let animationFrame;

// Listen for the input being dragged
clippingSlider.addEventListener("input", (event) => {
  // If an animation frame is already queued up, cancel it
  if (animationFrame) cancelAnimationFrame(animationFrame);

  // Tell the browser to update our component when it is ready
  // to repaint.
  animationFrame = requestAnimationFrame(() => {
    this.shadowRoot.host.style.setProperty(
      "--exposure",
      `${target.newValue}%`
    );
  });
});

Styling the input

With a handful of lines of HTML, CSS, and JS, we’re 90% there! We can now visually compare two images using a slider. And, since we’re using native browser controls our code is accessible and performant! But, this doesn’t quite match the design I had in mind.

Visually hiding the label

It’s critical that the input has a label to provide context to screen reader users about what the input controls. But the label felt redundant in the context of the blog post. By applying some special CSS I can visually hide the label text while still exposing it to assistive technology like screen readers:

<label class="image-compare-label">
  <span class="visually-hidden"
    >Select what percentage of the bottom image to show</span
  >
  <input type="range" min="0" max="100" class="image-compare-input" />
</label>
.visually-hidden {
  border: 0;
  clip: rect(0 0 0 0);
  height: 1px;
  margin: -1px;
  overflow: hidden;
  padding: 0;
  position: absolute;
  width: 1px;
}

Positioning the slider

Next up, I want to make the slider full-width, and center it over the images. First we need to position the label over the images:

.image-compare-label {
  /* Position the label over the images */
  position: absolute;
  top: 0;
  left: 0;
  bottom: 0;
  right: 0;
  /* Stretch the input so it fills the label vertically */
  align-items: stretch;
  display: flex;
}

Then we need to make the input span the full width of the images. I actually want the input to extend a little bit off the left and right edges, so that the center of the slider’s draggable “thumb” control will line up. For now we’ll estimate that the size of the thumb is 15 pixels, but we’ll circle back later to make sure this is consistent across browsers.

.image-compare-input {
  --thumb-size: 15px;

  /* Go half a "thumb" off the edge to the left and right" */
  margin: 0 calc(var(--thumb-size) / -2);
  /* Make the input a full "thumb" wider than 100% so it extends past the edges */
  width: calc(100% + var(--thumb-size));
}

Now we’re getting somewhere!

Hiding the slider bar

Now it’s time to customize the slider! First off, we need to remove the browser’s default styles and background:

.image-compare-input {
  appearance: none;
  -webkit-appearance: none;
  background: none;
  border: none;
}

I’m also going to give it a special CSS cursor to make its use more obvious:

.image-compare-input {
  cursor: col-resize;
}

Now the bar behind the slider thumb is hidden, but the thumb control isn’t very obvious:

Styling the Thumb

Let’s make the thumb itself more obvious. To style range input thumbs we need to apply CSS to some funky browser-specific selectors. Due to how browsers parse selectors they don’t understand, these styles need to be applied separately for each browser engine:

/* Firefox */
.image-compare-input::-moz-range-thumb {
  /* thumb styles */
}

.image-compare-input:focus::-moz-range-thumb {
  /* thumb focus styles */
}

/* Chrome, Safari and Edge, */
:.image-compare-input: -webkit-slider-thumb {
  -webkit-appearance: none;
  /* thumb styles */
}

.image-compare-input:focus::-webkit-slider-thumb {
  /* thumb focus styles */
}

(The Open UI group is working on standardizing range inputs so hopefully they’ll be easier to style in the future.)

Here’s the full list of styles I ended up applying to thumbs. (I also bumped up the --thumb-size custom property quite a bit.)

.image-compare-input::-funky-browser-specific-css-selector {
  /* A white background with slight transparency */
  background-color: hsla(0, 0%, 100%, 0.9);
  /* An inline SVG of two arrows facing opposite directions */
  background-image: url('data:image/svg+xml;utf8,<svg viewbox="0 0 60 60"  width="60" height="60" xmlns="http://www.w3.org/2000/svg"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="4" d="M20 20 L10 30 L20 40"/><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="4" d="M40 20 L50 30 L40 40"/></svg>');
  background-size: 90%;
  background-position: center center;
  background-repeat: no-repeat;
  border-radius: 50%;
  border: 2px hsla(0, 0%, 0%, 0.9) solid;
  color: hsla(0, 0%, 0%, 0.9);
  width: var(--thumb-size);
  height: var(--thumb-size);
}

I also added a box-shadow outline when the input is focused:

.image-compare-input:focus::-funky-browser-specific-css-selector {
  box-shadow: 0px 0px 0px 2px hsl(200, 100%, 80%);
}

Now we’re getting somewhere:

Adding a vertical divider

However, the input thumb feels like it’s disconnected and floating over the images. Adding a vertical divider between the images could help anchor the thumb.

This divider will need to stick to the left side of the clipped image. I considered adding a new element for the divider and controlling its position using our --exposure custom property, but then realized I could apply a shadow to the clipped image instead using the CSS drop-shadow filter.

Using a drop-shadow filter required adding a wrapper element around the clipped image (so that the drop shadow itself does not get clipped.)

<span class="image-2-wrapper">
  <img class="image-2" src="path/to/image" alt="alt text" />
</span>
.image-2-wrapper {
  filter: drop-shadow(-2px 0 0 hsla(0, 0%, 0%, 0.9));
  /*
    Since CSS filters create a new positioning context, 
    we need to move some CSS rules from our image to the wrapper
  */
  display: block;
  position: absolute;
  top: 0;
  width: 100%;
}

This works, but the divider is slightly off-center. We can adjust our clipping mask by a pixel (half the width of the divider) to fix this:

.image-2 {
  clip-path: polygon(
    calc(var(--exposure) + 1px) 0,
    100% 0,
    100% 100%,
    calc(var(--exposure) + 1px) 100%
  );
}

Now the thumb feels more anchored:

Packaging it up

Now we’ve got a functioning image comparison widget, but there are a few issues that make it tricky to reuse:

  1. Our JavaScript only supports a single image comparison widget.
  2. If there’s other CSS on the page it could clash with our styles and break the widget.
  3. It requires you add a script and a stylesheet and use some pretty specific HTML markup.

Luckily, all of these problems can be solved by packaging it up as a web component! I’ll dive into this more in a follow-up post, but here’s a quick look at the code:

// Create a template to house our HTML markup
const template = document.createElement("template");

template.innerHTML = `
  <style>
    /* CSS Styles go here... */
  </style>

  <slot name="image-1"></slot>
  <slot name="image-2"></slot>

  <label>
    <span class="visually-hidden js-label-text">
      Select what percentage of the bottom image to show
    </span>
    <input type="range" value="50" min="0" max="100"/>
  </label>
`;

class ImageCompare extends HTMLElement {
  constructor() {
    super();
    // Use the Shadow DOM to scope CSS styles
    this.attachShadow({ mode: "open" });
  }

  // Store an animation frame so we can keep track of scheduled
  // repaints and cancel old repaints that haven't happened yet
  animationFrame;

  connectedCallback() {
    // Apply our template markup
    this.shadowRoot.appendChild(template.content.cloneNode(true));

    // Add our event listener
    this.shadowRoot
      .querySelector("input")
      .addEventListener("input", ({ target }) => {
        // If an animation frame is already queued up, cancel it
        if (this.animationFrame) cancelAnimationFrame(this.animationFrame);

        // Tell the browser to update our component when it
        // is ready to repaint.
        this.animationFrame = requestAnimationFrame(() => {
          this.shadowRoot.host.style.setProperty(
            "--exposure",
            `${target.value}%`
          );
        });
      });
  }
}

// Define our custom element so it can be used.
customElements.define("image-compare", ImageCompare);

With a few extra lines of JavaScript, our widget is bundled up as a custom element which can be used with the <image-compare> tag. Images can be passed in using slots, and our code is encapsulated to prevent conflicts with other CSS or JS using the Shadow DOM.

Browser controls to the rescue

When I started planning this component I felt a little overwhelmed. I had to handle input from mice, keyboards, touch screens, and screen readers. It had to be accessible and understandable by everyone. And it had to be performant. I considered going down a rabbit hole of adding mouse, touch, and keyboard event listeners to dynamically update a custom slider.

But then I remembered that browsers already had a control that did exactly what I needed. By using a native browser control, I could let the browser do the heavy lifting for me and focus on polishing the visual interface. With a little bit of CSS trickery, I could turn a range input and a couple images into exactly the interface I wanted.

Try it yourself!

Now that it’s packaged as a web component it’s easy to include in projects. Feel free to use it wherever. For more information you can view the docs, install it from npm, or view the source code on GitHub.

Paul Hebert

Paul Hebert is a hybrid designer and developer at Cloud Four. When he's not designing and developing websites he enjoys bouldering, drawing, cooking, gardening, and eating too much cheese.

Never miss an article!

Get Weekly Digests


Comments

Add a comment

This is extremely well written. Thank you for leveraging the native web APIs and showing how easy this could be.

Replies

Thanks Ahmad!

I’ve been enjoying the web component APIs and am looking forward to writing about them more!

I think a lot of the tooling around web components is really powerful, but makes web components seem more complicated than they are. I’m excited to incorporate more simple one-off web components into my work, and share them with the community.

thank you! It was interesting to see how you styled the inout , and how you turned a simple one-time script into a web component. It’s time to think about performance and fps. it seems that you forgot to add requestAnimationFrame to the inout handler, because if the user sharply pulls this inout on a weak device, lags will appear.

Leave a Comment

Please be kind, courteous and constructive. You may use simple HTML or Markdown in your comments. All fields are required.


Let’s discuss your project! Email Us