Building an accessible image comparison web component

Programming - Apr 19, 2024

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.


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>
Code language: HTML, XML (xml)

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

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

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

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>
Code language: HTML, XML (xml)

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;
}
Code language: CSS (css)

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%);
}
Code language: CSS (css)

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%
  );
}
Code language: CSS (css)

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>
Code language: HTML, XML (xml)

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);
});
Code language: JavaScript (javascript)

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}%`
    );
  });
});
Code language: JavaScript (javascript)

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.

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>
Code language: HTML, XML (xml)
.visually-hidden {
  border: 0;
  clip: rect(0 0 0 0);
  height: 1px;
  margin: -1px;
  overflow: hidden;
  padding: 0;
  position: absolute;
  width: 1px;
}
Code language: CSS (css)

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;
}
Code language: CSS (css)

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));
}
Code language: CSS (css)

Now we’re getting somewhere!

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;
}
Code language: CSS (css)

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

.image-compare-input {
  cursor: col-resize;
}
Code language: CSS (css)

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

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 */
}
Code language: CSS (css)

(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);
}
Code language: CSS (css)

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%);
}
Code language: CSS (css)

Now we’re getting somewhere:

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>
Code language: HTML, XML (xml)
.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%;
}
Code language: CSS (css)

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%
  );
}
Code language: CSS (css)

Now the thumb feels more anchored:

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);
Code language: JavaScript (javascript)

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.

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.

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.

Previous Next
Copyrights
We respect the property rights of others, and are always careful not to infringe on their rights, so authors and publishing houses have the right to demand that an article or book download link be removed from the site. If you find an article or book of yours and do not agree to the posting of a download link, or you have a suggestion or complaint, write to us through the Contact Us .
Read More