Image Compare

The mountains in black and white (before). The mountains in color (after).
Drag the handle (or use the arrow keys) to compare the photos.
@use 'sprucecss/scss/spruce' as *;

.image-compare {
  --position: 50%;
  margin: 0;
  position: relative;
  @include layout-stack('xs');

  &__frame {
    border-radius: radius('lg');
    display: grid;
    overflow: hidden;
    position: relative;
  }

  &__image {
    block-size: 100%;
    grid-area: 1 / 1;
    inline-size: 100%;
    object-fit: cover;
    user-select: none;

    &--after {
      clip-path: inset(0 0 0 var(--position));
    }
  }

  &__handle {
    background-color: hsl(0deg 0% 100%);
    box-shadow: 0 0 0 1px hsl(0deg 0% 0% / 20%);
    inline-size: 3px;
    inset-block: 0;
    inset-inline-start: var(--position);
    pointer-events: none;
    position: absolute;
    translate: -50% 0;

    &::after {
      background-color: hsl(0deg 0% 100%);
      block-size: 2.5rem;
      border-radius: 50%;
      box-shadow: shadow('md');
      color: hsl(0deg 0% 10%);
      content: '↔';
      display: grid;
      font-weight: 700;
      inline-size: 2.5rem;
      inset-block-start: 50%;
      inset-inline-start: 50%;
      place-content: center;
      position: absolute;
      translate: -50% -50%;
    }
  }

  &__tag {
    background-color: hsl(0deg 0% 0% / 55%);
    border-radius: radius('pill');
    color: hsl(0deg 0% 100%);
    font-size: config('font-size-sm', $typography);
    inset-block-start: spacer('s');
    padding-block: spacer('xxs');
    padding-inline: spacer('s');
    position: absolute;

    &--before {
      inset-inline-start: spacer('s');
    }

    &--after {
      inset-inline-end: spacer('s');
    }
  }

  &__range {
    appearance: none;
    background: transparent;
    block-size: calc(100% - 2rem);
    cursor: ew-resize;
    inline-size: 100%;
    inset-block-start: 0;
    inset-inline-start: 0;
    margin: 0;
    opacity: 0;
    position: absolute;

    &:focus-visible {
      opacity: 0;
    }
  }

  &:has(&__range:focus-visible) &__frame {
    outline: 2px solid color('focus-ring');
    outline-offset: 3px;
  }

  &__caption {
    color: color('muted');
    font-size: config('font-size-sm', $typography);
    text-align: center;
  }
}

[dir='rtl'] .image-compare__image--after {
  clip-path: inset(0 var(--position) 0 0);
}

[dir='rtl'] .image-compare__handle {
  translate: 50% 0;
}
<figure class="image-compare" style="--position: 50%;" data-image-compare>
    <div class="image-compare__frame">
        <img class="image-compare__image" src="https://images.unsplash.com/photo-1506905925346-21bda4d32df4?auto=format&amp;fit=crop&amp;w=1200&amp;q=70&amp;sat=-100" alt="The mountains in black and white (before)." width="1200" height="800">
        <img class="image-compare__image image-compare__image--after" src="https://images.unsplash.com/photo-1506905925346-21bda4d32df4?auto=format&amp;fit=crop&amp;w=1200&amp;q=70" alt="The mountains in color (after)." width="1200" height="800">
        <span class="image-compare__handle" aria-hidden="true"></span>
        <span class="image-compare__tag image-compare__tag--before" aria-hidden="true">Before</span>
        <span class="image-compare__tag image-compare__tag--after" aria-hidden="true">After</span>
    </div>
    <label class="sr-only" for="image-compare-range">Comparison position</label>
    <input class="image-compare__range" id="image-compare-range" type="range" min="0" max="100" value="50" data-image-compare-range>
    <figcaption class="image-compare__caption">Drag the handle (or use the arrow keys) to compare the photos.</figcaption>
</figure>
(() => {
  document.querySelectorAll('[data-image-compare]').forEach((component) => {
    const range = component.querySelector('[data-image-compare-range]');
    const update = () => component.style.setProperty('--position', `${range.value}%`);

    range.addEventListener('input', update);
    update();
  });
})();

Image Compare

On this page

A before/after image comparison slider.

Technical Details

  • The control is a native, invisible input type="range" stretched over the images: drag, click, touch and the keyboard (arrow keys, Home/End, Page Up/Page Down) all work, and screen readers announce the value. Its focus ring is drawn around the images.
  • The only JavaScript sets one custom property (--position); the "after" image is cut with clip-path and the handle follows with inset-inline-start, both mirrored in RTL.
  • Both images have their own alt, describing the difference.
  • Override the images, the labels and the caption with imageCompare ({ before, beforeAlt, after, afterAlt, beforeLabel, afterLabel, caption }).

Documentation

Learn about Spruce CSS through our extensive documentation.

Components

Explore our UI library of accessible components built with Spruce CSS.

Blocks and Pages

Full-width page blocks and complete page templates, ready to copy.

Find us on GitHub

Did you find a bug? Have an idea or a question? Please open an issue to help us develop the project.