npm GitHub

Compare Images Slider

npm version script gzip license mit

A before/after image comparison slider β€” a custom element or a class you instantiate, with no shadow DOM to fight.

Building, before edit
Building, after edit

A live <compare-images-slider>, upgraded on connect with no JavaScript instantiation. Photo by Nenad RadojčiΔ‡ on Unsplash. Drag the handle, or focus it and use the arrow keys.

Overview

Compare Images Slider overlays two images β€” or two videos, or anything else you put in the markup β€” and reveals one over the other as you drag a handle across them. It is lightweight, mobile friendly, and works as a <compare-images-slider> custom element or as a class you instantiate. Because it uses no shadow DOM, every part stays yours to style.

Usage

Drop in the markup and the tag upgrades itself on connect β€” no JavaScript call needed. Options are read from attributes (bare, data-*, or kebab-case). Set the intrinsic width/height on the images so the layout does not shift while they load.

<compare-images-slider inertia initial-position="35">
  <img width="1680" height="1120" src="before.jpg" alt="Before" />
  <div class="compare-images-slider-reveal">
    <img width="1680" height="1120" src="after.jpg" alt="After" />
  </div>
  <span class="compare-images-slider-handle"></span>
</compare-images-slider>

Both children are required. Where they are missing, the two entry points say so differently: new CompareImagesSlider(el) throws, while <compare-images-slider> reports on the console, because a throw from a custom element's reaction callback goes to window.onerror and leaves the page believing it has a slider. Neither sits silent. The tag upgrades wherever the script is loaded β€” head, deferred or last in <body> β€” waiting for the parser to reach its children when it has to.

Not only images

The name says images and images are the point, but nothing in the script reads the content: the reveal layer covers the whole slider and is clipped back to the current position, so whatever sits inside it is what gets revealed. Two videos, a canvas over a screenshot, a styled div over a photo β€” all the same to it.

<compare-images-slider>
  <video
    src="graded.mp4"
    width="1280"
    height="720"
    autoplay
    muted
    loop
    playsinline
  ></video>
  <div class="compare-images-slider-reveal">
    <video
      src="ungraded.mp4"
      width="1280"
      height="720"
      autoplay
      muted
      loop
      playsinline
    ></video>
  </div>
  <span class="compare-images-slider-handle"></span>
</compare-images-slider>

The stylesheet sizes img, video, canvas and picture > img on both layers. Anything else is yours to size, and the rule is that the two layers must lay out identically: the base layer is what gives the slider its height, and the reveal layer is laid over it.

Without the custom element

The same markup on a plain element works too β€” give it the compare-images-slider class for the styles, and instantiate the slider on it yourself. That is the path for markup whose tag name is not yours to choose, or a slider built after load.

<div class="js-compare-images-slider compare-images-slider">
  <img width="1680" height="1120" src="before.jpg" alt="Before" />
  <div class="compare-images-slider-reveal">
    <img width="1680" height="1120" src="after.jpg" alt="After" />
  </div>
  <span class="compare-images-slider-handle"></span>
</div>
import CompareImagesSlider from 'compare-images-slider';

const el = document.querySelector('.js-compare-images-slider');
new CompareImagesSlider(el, { inertia: true });

Loading the bundle asynchronously? Wait for the CompareImagesSliderLoaded event before instantiating:

document.addEventListener('CompareImagesSliderLoaded', () => {
  const el = document.querySelector('.js-compare-images-slider');
  new CompareImagesSlider(el, { inertia: true });
});
Building, before edit
Building, after edit

The same slider as a div, instantiated by hand β€” same markup, same behaviour.

Vertical

Add the vertical attribute (or pass { vertical: true }) to split top and bottom instead of left and right.

<compare-images-slider vertical>
  <!-- ...same inner markup... -->
</compare-images-slider>
Tree, before edit
Tree, after edit

Photo by Valentin Salja on Unsplash.

Options

Option Type Default Description
inertia boolean false Keep gliding after a flick.
bounce boolean false Bounce off the edges while under inertia.
friction number 0.9 Inertia decay per frame (0–1).
bounceFactor number 0.1 Energy kept on a bounce (0–1).
maxFlickVelocity number 0.5 Cap on flick velocity (% per ms).
dragAnywhere boolean false A press anywhere on the images drags.
vertical boolean false Split top/bottom instead of left/right.
initialPosition number 50 Starting position (0–100).
step number 5 Arrow-key step (percent).
pageStep number 25 Page Up/Down step (percent).

Each one is an attribute in kebab-case - bare, or data- prefixed - and the same name in camelCase on the options object, read whether you write <compare-images-slider> or call the constructor yourself. An attribute beats the options object, and a boolean is turned off with ="false".

Note

Set the intrinsic dimensions of the images. Nothing in the slider measures them β€” the reveal is clipped by percentage β€” but without them the page reflows as they load, which moves the slider out from under the pointer mid-drag.

Try them

The block below is the sample and the thing it documents at once: the frame above renders the markup under it, and Options is that table again as knobs, built from the manifest this package ships. Turning one rewrites the attribute in the code, so what you read is always what is running.

<compare-images-slider inertia initial-position="35">
  <img
    width="1680"
    height="1120"
    src="https://i.imgur.com/Ju4pEb7.jpeg"
    alt="Building, before edit"
  />
  <div class="compare-images-slider-reveal">
    <img
      width="1680"
      height="1120"
      src="https://i.imgur.com/pvWyCKw.jpeg"
      alt="Building, after edit"
    />
  </div>
  <span class="compare-images-slider-handle"></span>
</compare-images-slider>

The frame loads only compare-images-slider.min.css, so the handle there is the library's own ↔ glyph rather than the svg knob this page draws over it. That knob is the optional theme plus a span in this page's markup β€” both shipped, neither loaded in the frame.

Flick physics

Quick flicks used to shoot straight to the edges. Velocity is now sampled over a short time window instead of a single frame and capped, so a fast flick carries naturally without slamming to an extreme. Friction is applied frame-rate independently, so the feel is consistent across displays.

Events

Four events on the element, each bubbling, each carrying the position it happened at as detail.position β€” a number from 0 to 100, unrounded.

Event Fires
input Every move as it happens: a drag, a key, a frame of inertia. What <input type="range"> calls it
change Once the position settles: a press released, a flick come to rest, a key pressed
start The handle has just reached 0 β€” the pane fully collapsed
end The handle has just reached 100 β€” the pane fully revealed
slider.addEventListener('input', (e) => {
  label.textContent = Math.round(e.detail.position) + '%';
});

slider.addEventListener('change', (e) => {
  history.replaceState(null, '', '#' + Math.round(e.detail.position));
});

input and change split the same way they do on a range input: the first is for anything that should track the handle, the second for anything you would rather not do sixty times a second. A gesture that ends where it started fires neither β€” a press that goes nowhere is not a change.

start and end fire on arrival, not while the handle sits there. A held arrow key at 0, a second Home, an inertia glide clamped against the edge for several frames β€” one start between them. They mark the moment to swap a caption, not a state to poll.

Accessibility

The handle follows the W3C APG Window Splitter pattern: it is a focusable role="separator" exposing aria-valuenow, aria-valuemin, aria-valuemax, and aria-orientation, carrying an accessible name, and wired to the revealed pane β€” the pattern's primary pane β€” via aria-controls. Once focused it is fully keyboard operable. Which keys the pattern asks for and which are this library's own:

Key Action From the pattern?
Left / Up Move back by one step Yes
Right / Down Move forward by one step Yes
Enter Collapse the revealed pane, or put it back Yes
Home / End Jump to either extreme Yes, optional
Page Up / Page Down Move by one pageStep No, ours
Double-click or double-tap Snap to the extreme the handle is further from No, ours

The pattern's other optional key, F6, cycles focus between window panes. There is one pane here and nothing to cycle to, so it is not implemented.

Naming the handle

The accessible name is Image comparison slider until you give it one, and that string is English with no locale switch behind it. Both ARIA naming attributes work, on the .compare-images-slider-handle or on the element itself β€” from the element they are copied down onto the handle, so the translated string sits in the markup you already render:

<h2 id="restoration-label">Pre i posle restauracije</h2>
<compare-images-slider aria-labelledby="restoration-label">
  ...
</compare-images-slider>

aria-labelledby wins over aria-label, as it does in ARIA itself. An attribute already on the handle is left alone, so nothing here overwrites a name you set there directly.

Two details worth knowing before reading the DOM and calling either a bug:

The pattern spent on resizing rather than revealing β€” two panes dividing one width, with a seam handing space from one to the other β€” is <splitter-elemental> in book-of-elementals: same roles, same keys, the same drag() underneath, and it is the one to reach for when what moves is layout rather than a clip.

Styling

There is no shadow DOM, so the element and its children are styled directly. Where the reveal starts is a CSS custom property, so the edge is in the right place in the first painted frame rather than after the script has run:

compare-images-slider {
  --compare-images-slider-initial-position: 35%;
}

Where it is now is a second property, --compare-images-slider-position, written on the element on every render β€” the same value the reveal layer is clipped to and the handle sits at. Read it to hang your own styling off the reveal; setting it is overwritten by the next move.

compare-images-slider .caption {
  left: var(--compare-images-slider-position);
}

Optional theme

compare-images-slider.css is the slider working β€” the layers, the handle, the reveal. The handle it draws is an ↔ glyph in a white circle, and white on black is all it can be: the stylesheet knows nothing about the page it landed on.

The look done properly is a second file, because a light-DOM element cannot scope a look away from a page that never asked for one:

<link
  rel="stylesheet"
  href="https://unpkg.com/compare-images-slider/dist/compare-images-slider-theme.min.css"
/>
@use "compare-images-slider/src/styles/theme";

It paints the handle in Canvas/CanvasText, the page's own pair β€” so the knob inverts with a dark page, and is repainted rather than lost in forced-colors mode β€” and it takes an icon of your own from a .compare-images-slider-handle-knob span inside the handle, which is the knob this page wears:

<span class="compare-images-slider-handle">
  <span class="compare-images-slider-handle-knob">
    <svg viewBox="0 0 16 16" width="16" height="16"><!-- your icon --></svg>
  </span>
</span>

The knob is markup, so the icon is yours; a handle without one keeps the ↔ glyph, because every rule in the theme sits behind :has(.compare-images-slider-handle-knob).

Note

Canvas reads the page's color-scheme. A page that themes in custom properties without declaring one keeps a light Canvas when it goes dark, and the knob goes with it. Point the two properties at that page's own tokens instead β€” two lines, and what this page does:

compare-images-slider {
  --compare-images-slider-handle-bg: var(--bg);
  --compare-images-slider-handle-fg: var(--fg);
}

One dependency

book-of-spells is the one thing installed alongside this element, and it is not a third party: it is the sibling spellbook, where behaviour that turned out to belong to more than one package lives once rather than being written out again in each. The pointer half of this slider used to be written out here β€” the capture, the move and up and cancel listeners, the pointer-id matching, the sum turning a client coordinate into a percentage, the flick and the glide after it β€” all of which drag() already did. Two spells are taken now, and the gesture among them is the one the elements in book-of-elementals start their own drags with; the other rides inside it:

Taken from it What it does here Shared with
drag(), src/dom.mjs The gesture, both halves: under the pointer, the capture, a pointercancel told apart from a release, the moves heard on the document, the client coordinate turned into a percentage of the track; after it, the glide β€” the flick sampled over a window and capped, the friction, the wall it stops at or bounces off <splitter-elemental> and <rearrange-elemental>
clamp(), src/helpers.mjs Holds the position to 0–100 under the keys and the collapse drag(), which holds the flick to its ceiling with it

The physics above is drag()'s too. One thing stays, because drag() cannot know it: the double tap, which is no part of a drag.

How it compares

There are a dozen of these, several of them good. What follows is what each one weighs and what it does, measured rather than quoted: sizes are gzip -9 over the files the package actually ships, and every behaviour row was read out of those shipped bundles rather than taken from a README. Taken on 23 August 2026, against the versions named under the table β€” bar this package's own column, re-measured a day later, when the gesture moved into book-of-spells and the bundle grew by it.

Listed: every comparable package on npm over a thousand downloads a week, plus JuxtaposeJS for the name it has β€” its npm mirror is a 2015 build, so it is measured from its own CDN instead.

this one img-comparison-slider react-compare-slider cocoen JuxtaposeJS image-compare-viewer
Gzipped 4.8 kB 3.7 kB 3.6 kB + React 2.5 kB 5.3 kB 17.2 kB
Runtime dependencies one, the sibling spellbook none React none none five
Reveals any element any slotted element any React node any slotted element images, from URLs two images
Pointer Events, with capture βœ” β€” βœ” β€” β€” β€”
Keyboard arrows, Page, Home/End, Enter arrows arrows β€” arrows β€”
ARIA on the handle role="separator", aria-valuenow/min/max, aria-orientation, aria-controls β€” role="slider", aria-valuenow/min/max, aria-orientation β€” role="slider", aria-valuenow/min/max β€”
Inertia, capped flick, bounce βœ” β€” β€” β€” β€” β€”
Double-tap snap to an extreme βœ” β€” β€” β€” β€” β€”
Enter collapses the pane βœ” β€” β€” β€” β€” β€”
Vertical βœ” βœ” βœ” βœ” βœ” βœ”
Events input, change, start, end slide onPositionChange four lifecycle events β€” β€”
Styling surface light DOM shadow root React props shadow root light DOM light DOM

compare-images-slider from main with the book-of-spells change in it, re-measured 24 August 2026 Β· img-comparison-slider 8.0.7 Β· react-compare-slider 4.0.0 Β· cocoen 3.2.0 Β· JuxtaposeJS from its CDN Β· image-compare-viewer 1.6.2. A dash means the shipped bundle has none of it. Sizes are script plus stylesheet, or script alone where the CSS is inlined into a shadow root; the optional theme is not counted.

Two rows carry it. This is the only one of the six implementing the W3C Window Splitter pattern β€” role, values, orientation, the pane it controls, the Enter key that collapses it β€” where img-comparison-slider is keyboard operable and ships no ARIA at all, handing a screen reader a focusable something with no name, role or value. The gestures are its own too: pointer capture, so a fast drag cannot fall out of the handle, inertia with a capped flick, and a double-tap snap that no other package here has. What it costs is the size row: 4.8 kB with the gesture bundled in, where the same element was 3.3 kB before it shared one, which puts three of the six below it β€” cocoen at 2.5 kB, react-compare-slider at 3.6 kB before React is counted, img-comparison-slider at 3.7 kB.

Where it loses: light DOM is a trade rather than a free win β€” the openness that lets you restyle the handle also lets a stray img { max-width } rule from the page reach in, where cocoen and img-comparison-slider sit sealed in a shadow root, so pick those if you ship into pages you do not control.

React needs no wrapper here, and there will not be one. React 19 registers any unknown on* prop as a listener, so oninput, onchange, onstart and onend bind straight in JSX, and every option was an attribute already; React 18 wants a ref and addEventListener for the events, as it does for any web component. react-compare-slider is the React-only package of the six β€” this is the same element in React and out of it.