Build Your Own - Accessible Image Compare Web Component - No Shadow DOM
Build Your Own - Accessible Image Compare Web Component - No Shadow DOM

Build Your Own: Accessible Image Compare Web Component (No Shadow DOM)

Image comparison tools are great for showcasing before-and-after scenarios, highlighting product features, or simply adding an interactive element to your website. This tutorial will guide you through building an accessible <image-compare> web component without using Shadow DOM. Using Light DOM offers greater CSS styling flexibility.

What We’re Building

We’ll be creating a custom HTML element, <image-compare>, that:

  • Displays two images side-by-side with a draggable slider.
  • Allows users to control the visibility of each image.
  • Is accessible to screen readers.
  • Can be easily styled with standard CSS.
  • Works without JavaScript enabled (horizontal scrolling fallback).

Prerequisites

  • Basic knowledge of HTML, CSS, and JavaScript.
  • Understanding of Web Components (custom elements).
  • A text editor (like VS Code, Sublime Text, etc.).
  • A web browser (Chrome, Firefox, Safari, etc.).

Step 1: Setting up the HTML

First, let’s define the basic HTML structure using the custom element.

<image-compare>
    <img alt="Alt text" src="image1.jpg" width="600" height="400" />
    <img alt="Alt text" src="image2.jpg" width="600" height="400" />
</image-compare>
  • <image-compare>: This is our custom element. It will contain the two images and the slider.
  • <img> tags: These are the two images we want to compare. Make sure to include alt attributes for accessibility!

You can also wrap the image-compare into <figure> tag for semantic reasons.

Step 2: Writing the CSS

Add the following CSS to your stylesheet to style the component. Key aspects are setting up custom properties, positioning the images, and styling the slider. The CSS provides a default appearance and a fallback for when JavaScript isn’t available.

/* Remove default margin if nested in a figure */
figure:has(image-compare) { margin-inline: 0; }

image-compare {
 & {
  --exposure: 50%;
  --thumb-background-color: hsla(0, 0%, 100%, 0.9);
  --thumb-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>');
  --thumb-size: clamp(3em, 10vmin, 5em);
  --thumb-radius: 50%;
  --thumb-border-color: hsla(0, 0%, 0%, 0.9);
  --thumb-border-size: 2px;
  --focus-width: var(--thumb-border-size);
  --focus-color: hsl(200, 100%, 80%);
  --divider-width: 2px;
  --divider-color: hsla(0, 0%, 0%, 0.9);
 }
 & {
  position: relative;
  display: flex;
  width: fit-content;
  max-width: 100%;
  margin-inline: auto;
  img { width: 100%; height: auto; }
  label {
   position: absolute;
   inset: 0;
   display: flex;
   align-items: stretch;
  }
  [type=range] {
   cursor: col-resize;
   appearance: none;
   -webkit-appearance: none;
   background: none;
   border: none;
   margin: 0 calc(var(--thumb-size) / -2);
   width: calc(100% + var(--thumb-size));
   height: unset;
  }
  /* Styling range inputs requires separately defining the CSS rules for -moz and -webkit. */
  /* ! bug in Safari when trying to nest -moz and -webkit together under the same [type=range] */
  [type=range] {
   &::-moz-range-track { height: unset; background: transparent; }
   &::-moz-range-thumb {
    background-color: var(--thumb-background-color);
    background-image: var(--thumb-background-image);
    background-size: 90%;
    background-position: center center;
    background-repeat: no-repeat;
    border-radius: var(--thumb-radius);
    border: var(--thumb-border-size) var(--thumb-border-color) solid;
    color: var(--thumb-border-color);
    width: var(--thumb-size);
    height: var(--thumb-size);
    margin: 0;
   }
   &:focus::-moz-range-thumb { box-shadow: 0px 0px 0px var(--focus-width) var(--focus-color); }
  }
  [type=range] {
   &::-webkit-slider-runnable-track { height: unset; background: transparent; }
   &::-webkit-slider-thumb {
    -webkit-appearance: none;
    background-color: var(--thumb-background-color);
    background-image: var(--thumb-background-image);
    background-size: 90%;
    background-position: center center;
    background-repeat: no-repeat;
    border-radius: var(--thumb-radius);
    border: var(--thumb-border-size) var(--thumb-border-color) solid;
    color: var(--thumb-border-color);
    width: var(--thumb-size);
    height: var(--thumb-size);
    margin: 0;
   }
   &:focus::-webkit-slider-thumb { box-shadow: 0px 0px 0px var(--focus-width) var(--focus-color); }
  }
 }
 &:not(:defined) {
  flex-direction: row;
  overflow-x: auto;
 }
 &:defined {
  flex-direction: column;
  overflow: clip;
  .image-2-wrapper {
   position: absolute;
   top: 0;
   /* shadow used for vertiacal divider */
   filter: drop-shadow( calc(-1*var(--divider-width)) 0 0 var(--divider-color) );
   img {
    --_top-left: calc(var(--exposure) + var(--divider-width)/2);
    --_bottom-left: calc(var(--exposure) + var(--divider-width)/2);
    clip-path: polygon(var(--_top-left) 0, 100% 0, 100% 100%, var(--_bottom-left) 100%);
   }
  }
 }
 /* adjecent figcaption */
 & + figcaption { 
  text-align: center; 
  margin-block-start: .5lh; 
 }
 /* utils */
 .visually-hidden {
  position: absolute;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap; /* added line */
  border: 0;
 }
}

/* You probably don't need the styles below */
@layer reset {
 *, *:before, *:after { box-sizing: border-box; }
/*  img { display: block; max-width: 100%; height: auto; } */
}
@layer demo {
 html { color-scheme: light dark; }
 body {
  font-family: system-ui;
  font-family: 'SF Pro Text', 'SF Pro Icons', 'AOS Icons', 'Helvetica Neue', Helvetica, Arial, sans-serif, system-ui;
  text-wrap: balance;
  text-wrap: pretty;
  line-height: 1.4;
  padding-inline: 1lh;
  max-width: 45rem;
  margin-inline: auto;
 }
 a {
  color: inherit; font-weight: bold;
  text-decoration: none;
  &:hover { text-decoration: underline; }
 }
}

Key CSS Properties Explained:

  • image-compare: Sets up relative positioning, flexbox layout, width constraints and margin.
  • image-compare img: Ensures images take up the full width.
  • label: Positions the label (containing the range input) absolutely, so it overlays the images.
  • [type=range]: Styles the range input (slider) to have a custom appearance, including hiding the default styling. Accessibility is improved by adding sr-only text in the label which is useful for assistive technologies.
  • &:not(:defined): Add a horizontal scroll for devices with js turned off.
  • &:defined: sets up the layering for the image comparison once the component has been registered.
  • .image-2-wrapper: Positions the second image absolutely and clips it. drop-shadow adds the divider.
  • clip-path: clip second image with exposure from the left

Step 3: Adding the JavaScript

Now, let’s define the <image-compare> web component using JavaScript. This is where we’ll handle the slider functionality and accessibility.

class ImageCompare extends HTMLElement {
 
 defaultExposure = 50;
 exposure = this.getAttribute('exposure') || this.defaultExposure;
 inputSelector = 'input[type=range]';
 secondImg = this.querySelectorAll('img')[1];
 
 /**
 * Generates an HTML string for a `<label>` containing a visually hidden
 * description for screen readers and an `<input type="range">` slider 
 * for setting the exposure value.
 */
 ui(exposure) { 
  return `
  <label>
   <span class="visually-hidden">
    Control how much of each overlapping image is shown.
    0 means the first image is completely hidden.
    100 means the first image is fully visible.
    50 means both images are half-shown, half-hidden.
   </span>
   <input type="range" value="${exposure}" min="0" max="100" />
  </label>`;
 }
 
 constructor() { super(); }

 connectedCallback() {
  // setTimeout(() => {
   this.#setPropPercentage('--exposure', this.defaultExposure);
   this.#wrapSecondImg('span', 'image-2-wrapper');
   this.#appendHtml(this.ui(this.exposure));
   this.#setupInputListener(this.inputSelector);
  // });
 }

 static get observedAttributes() { return ['exposure']; }

 attributeChangedCallback(name, oldValue, newValue) {
  if (name === 'exposure') {
    const rangeInput = this.querySelector(this.inputSelector);
    if (rangeInput) {
      rangeInput.value = newValue;
      this.#setPropPercentage('--exposure', newValue);
    }
  }
 }

 #setupInputListener(el) {
  const rangeInput = this.querySelector(el);
  const handleInputChange = () => {
   this.setAttribute('exposure', rangeInput.value);
  };
  rangeInput.addEventListener('input', handleInputChange);
  rangeInput.addEventListener('change', handleInputChange);
 }

 #appendHtml(rawHtml) {
  const tempDiv = document.createElement('div');
  tempDiv.innerHTML = rawHtml;
  while (tempDiv.firstChild) {
   this.appendChild(tempDiv.firstChild);
  }
 }
 
 #wrapSecondImg(tag, className) {
  this.wrap(this.secondImg, tag, className);
 }
 
 #setPropPercentage(prop, value) {
  this.style.setProperty(prop, value + '%');
 }
 
 wrap(el, tag, className) {
  const wrapper = Object.assign(
   document.createElement(tag), {
    className: className
   }
  );
  el.parentNode.insertBefore(wrapper, el);
  wrapper.appendChild(el);
 }
 
}

customElements.define('image-compare', ImageCompare);

Key JavaScript Functionality:

  • class ImageCompare extends HTMLElement { … }: Defines our custom element, inheriting from the base HTMLElement class.
  • connectedCallback(): This method is called when the element is added to the DOM. Here’s what it does:
    • Sets the initial exposure percentage.
    • Wraps the second image in a span with class image-2-wrapper for positioning.
    • Appends the UI (slider) to the component.
    • Sets up the event listener for the slider’s input event.
  • static get observedAttributes() { return [‘exposure’]; }: Tells the component to watch for changes to the exposure attribute.
  • attributeChangedCallback(name, oldValue, newValue): Called when an observed attribute changes. Updates the slider’s value and the exposure percentage when the exposure attribute is changed.
  • #setupInputListener(el): Listens for changes to the input range and updates the exposure of the images based on the input.
  • #appendHtml(rawHtml): A helper function to append HTML strings to the element.
  • #wrapSecondImg(tag, className): A helper function to wrap the second image in a span for correct positioning.
  • #setPropPercentage(prop, value): Sets a CSS custom property on the element’s style.
  • customElements.define(‘image-compare’, ImageCompare): Registers our custom element with the browser.

Step 4: Accessibility Considerations

This component prioritizes accessibility:

  • alt attributes: Always include descriptive alt attributes on your <img> tags.
  • Visually Hidden Text: Use screen reader text to describe the slider
  • Semantic HTML: Uses semantic HTML tags (<figure>, <figcaption>) where appropriate to provide context.
  • Keyboard Navigation: The slider (<input type=”range”>) is inherently keyboard navigable.

Step 5: Customization

  • CSS Variables: The CSS uses custom properties (variables) for easy customization of colors, sizes, and styles. Modify these variables to match your design.
  • exposure attribute: You can control the initial visibility of the images using the exposure attribute on the <image-compare> element (values from 0 to 100). For example: <image-compare exposure=”25″>
  • Images: Use your own images by replacing the src attributes on the <img> tags.
  • Caption: Add a <figcaption> to your <figure> element to provide context.

Demo / Embed

See the Pen Accessible <image-compare> Web Component (no Shadow DOM) by Pawan Mall (@iPawan) on CodePen.

Conclusion

You’ve now created a functional and accessible <image-compare> web component! This component provides a flexible and customizable way to showcase image comparisons on your website. The lack of Shadow DOM gives you complete control over styling with standard CSS.

154
0

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply