Compare Images Slider
A before/after image comparison slider β a custom element or a class you instantiate, with no shadow DOM to fight.
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 });
});
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>
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:
aria-orientationdescribes the divider, not the layout. A left/right slider has a divider running top to bottom, so it reportsvertical; averticalslider reportshorizontal.- The arrow keys are a superset of the pattern's. It asks for Left/Right on a vertical divider and Up/Down on a horizontal one; both pairs work on both orientations here, so a keyboard user who guesses wrong is not stuck.
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.