<div><span class="lbl">Drag slider with before and after labels</span>
<figure class="ic-figure">
<div class="ic" data-compare>
<img src="https://images.unsplash.com/photo-1501785888041-af3ef285b470?auto=format&fit=crop&w=900&h=600&q=70" srcset="https://images.unsplash.com/photo-1501785888041-af3ef285b470?auto=format&fit=crop&w=600&h=400&q=70 600w, https://images.unsplash.com/photo-1501785888041-af3ef285b470?auto=format&fit=crop&w=900&h=600&q=70 900w, https://images.unsplash.com/photo-1501785888041-af3ef285b470?auto=format&fit=crop&w=1200&h=800&q=70 1200w" sizes="(min-width: 1040px) 980px, calc(100vw - 32px)" width="1200" height="800" loading="lazy" alt="After: a wooden rowing boat on a bright turquoise lake below sunlit limestone peaks, with rich colour and contrast">
<div class="ic-before"><img src="https://images.unsplash.com/photo-1501785888041-af3ef285b470?auto=format&fit=crop&w=900&h=600&q=70" srcset="https://images.unsplash.com/photo-1501785888041-af3ef285b470?auto=format&fit=crop&w=600&h=400&q=70 600w, https://images.unsplash.com/photo-1501785888041-af3ef285b470?auto=format&fit=crop&w=900&h=600&q=70 900w, https://images.unsplash.com/photo-1501785888041-af3ef285b470?auto=format&fit=crop&w=1200&h=800&q=70 1200w" sizes="(min-width: 1040px) 980px, calc(100vw - 32px)" width="1200" height="800" loading="lazy" alt="Before: the same lake scene straight from the camera, flat and washed out"></div>
<span class="ic-tag ic-tag-b" aria-hidden="true">Before</span><span class="ic-tag ic-tag-a" aria-hidden="true">After</span>
<input class="ic-range" type="range" min="0" max="100" value="50" aria-label="Compare the unedited and graded photo of the lake" aria-valuetext="50% after" data-compare-range>
<span class="ic-handle" aria-hidden="true"><span class="ic-knob"><svg viewBox="0 0 24 24"><path d="M9 6l-6 6 6 6M15 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></span></span>
</div>
<figcaption><strong>Lago di Braies, colour grade</strong><span>Shot by Mira Kessler · RAW to final in 6 adjustments</span></figcaption>
</figure></div>
<div><span class="lbl">Card with pill handle and edit list</span>
<article class="ic-card">
<div class="ic" data-compare>
<img src="https://images.unsplash.com/photo-1470770841072-f978cf4d019e?auto=format&fit=crop&w=800&h=600&q=70" srcset="https://images.unsplash.com/photo-1470770841072-f978cf4d019e?auto=format&fit=crop&w=480&h=360&q=70 480w, https://images.unsplash.com/photo-1470770841072-f978cf4d019e?auto=format&fit=crop&w=800&h=600&q=70 800w, https://images.unsplash.com/photo-1470770841072-f978cf4d019e?auto=format&fit=crop&w=1240&h=930&q=70 1240w" sizes="(min-width: 680px) 592px, calc(100vw - 60px)" width="1240" height="930" loading="lazy" alt="Graded: a wooden boathouse on stilts in a green lake beneath cloud-wrapped mountains, in warm natural colour">
<div class="ic-before"><img src="https://images.unsplash.com/photo-1470770841072-f978cf4d019e?auto=format&fit=crop&w=800&h=600&q=70" srcset="https://images.unsplash.com/photo-1470770841072-f978cf4d019e?auto=format&fit=crop&w=480&h=360&q=70 480w, https://images.unsplash.com/photo-1470770841072-f978cf4d019e?auto=format&fit=crop&w=800&h=600&q=70 800w, https://images.unsplash.com/photo-1470770841072-f978cf4d019e?auto=format&fit=crop&w=1240&h=930&q=70 1240w" sizes="(min-width: 680px) 592px, calc(100vw - 60px)" width="1240" height="930" loading="lazy" alt="Original: the same boathouse photo before editing, dull and greyish" style="--ic-before:brightness(.96) contrast(.82) saturate(.45) sepia(.35)"></div>
<span class="ic-tag ic-tag-b" aria-hidden="true">Original</span><span class="ic-tag ic-tag-a" aria-hidden="true">Graded</span>
<input class="ic-range" type="range" min="0" max="100" value="50" aria-label="Compare the original and graded photo of the boathouse" aria-valuetext="50% after" data-compare-range>
<span class="ic-handle" aria-hidden="true"><span class="ic-knob"><svg viewBox="0 0 24 24"><path d="M9 7l-5 5 5 5M15 7l5 5-5 5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></span></span>
</div>
<div class="ic-card-body">
<div><h3>Boathouse at Braies</h3><p>Preset “Alpine Morning” by Studio Kessler</p></div>
<ul class="ic-edits" aria-label="Adjustments"><li>Exposure +0.4</li><li>Contrast +18</li><li>Vibrance +32</li></ul>
</div>
</article></div>
CSS
/* Image comparison slider. A real <input type="range"> sits over the photo pair: it gives keyboard,
touch and screen-reader support for free, and its value (0–100) drives --v, which positions the
divider and clips the "before" layer. --thumb keeps the divider centred on the native thumb. */
.ic{--v:50;--thumb:44px;--x:calc(var(--thumb) / 2 + (100% - var(--thumb)) * var(--v) / 100);
--ic-radius:16px;--ic-line:#fff;--ic-focus:#1d4ed8;--ic-before:brightness(1.06) contrast(.8) saturate(.3);
position:relative;display:block;aspect-ratio:3/2;overflow:hidden;border-radius:var(--ic-radius);background:#d8d3ca;isolation:isolate;user-select:none;-webkit-user-select:none}
.ic img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block;pointer-events:none}
.ic-before{position:absolute;inset:0;clip-path:inset(0 calc(100% - var(--x)) 0 0)}
.ic-before img{filter:var(--ic-before)}
.ic-tag{position:absolute;top:14px;padding:5px 11px;border-radius:999px;background:rgba(15,17,21,.78);color:#fff;
font-size:.72rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase;pointer-events:none;transition:opacity .2s}
.ic-tag-b{left:14px;opacity:clamp(0,calc((var(--v) - 14) / 8),1)}
.ic-tag-a{right:14px;opacity:clamp(0,calc((86 - var(--v)) / 8),1)}
.ic-range{position:absolute;inset:0;z-index:2;width:100%;height:100%;margin:0;opacity:0;cursor:ew-resize;-webkit-appearance:none;appearance:none;background:none}
.ic-range::-webkit-slider-runnable-track{height:100%}
.ic-range::-webkit-slider-thumb{-webkit-appearance:none;width:var(--thumb);height:100%}
.ic-range::-moz-range-thumb{width:var(--thumb);height:100%;border:0}
.ic-handle{position:absolute;top:0;bottom:0;left:var(--x);width:0;pointer-events:none;z-index:1}
.ic-handle::before{content:"";position:absolute;top:0;bottom:0;left:-1.5px;width:3px;background:var(--ic-line);box-shadow:0 0 10px rgba(0,0,0,.35)}
.ic-knob{position:absolute;top:50%;left:0;display:grid;place-items:center;width:46px;height:46px;translate:-50% -50%;border-radius:50%;
background:#fff;color:#16181d;box-shadow:0 4px 16px rgba(0,0,0,.3);transition:scale .2s}
.ic-knob svg{width:22px;height:22px}
.ic:hover .ic-knob{scale:1.08}
.ic-range:focus-visible ~ .ic-handle .ic-knob{outline:3px solid var(--ic-focus);outline-offset:3px;box-shadow:0 0 0 3px #fff,0 4px 16px rgba(0,0,0,.3)}
/* 1. Full-width slider with a caption */
.ic-figure figcaption{display:flex;flex-wrap:wrap;justify-content:space-between;gap:4px 20px;margin-top:14px;font-size:.92rem}
.ic-figure figcaption strong{font-weight:600}
.ic-figure figcaption span{color:#5f5a52}
/* 2. Card with a pill handle and the edit list */
.ic-card{max-width:620px;padding:14px;border-radius:22px;background:#fff;box-shadow:0 1px 2px rgba(22,24,29,.06),0 18px 40px -18px rgba(22,24,29,.28)}
.ic-card .ic{--ic-radius:12px;--ic-line:rgba(255,255,255,.9);aspect-ratio:4/3}
.ic-card .ic-handle::before{left:-1px;width:2px}
.ic-card .ic-knob{width:38px;height:62px;border-radius:999px}
.ic-card-body{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:12px;padding:16px 6px 4px}
.ic-card h3{font-size:1.05rem;font-weight:650;letter-spacing:-.01em}
.ic-card p{margin-top:2px;font-size:.85rem;color:#5f5a52}
.ic-edits{display:flex;flex-wrap:wrap;gap:6px;list-style:none}
.ic-edits li{padding:4px 10px;border-radius:999px;background:#eef0f4;font-size:.78rem;font-weight:600;color:#2b2f38}
@media (prefers-reduced-motion:reduce){.ic-tag,.ic-knob{transition:none}.ic:hover .ic-knob{scale:none}}
JavaScript
// Before/after slider: the range input is the control. This mirrors its value into --v
// (which moves the divider and clip) and gives screen readers a readable value.
document.querySelectorAll('[data-compare]').forEach((root) => {
const range = root.querySelector('[data-compare-range]');
if (!range) return;
const sync = () => {
root.style.setProperty('--v', range.value);
range.setAttribute('aria-valuetext', `${100 - range.value}% after`);
};
range.addEventListener('input', sync);
sync();
});
Bootstrap 5 code
Requires: Bootstrap 5.3.8 CSS
HTML
<div><span class="lbl">Drag slider with before and after labels</span>
<figure class="figure ic-figure d-block m-0">
<div class="ic ratio rounded-4" style="--bs-aspect-ratio:66.6667%" data-compare>
<img src="https://images.unsplash.com/photo-1501785888041-af3ef285b470?auto=format&fit=crop&w=900&h=600&q=70" srcset="https://images.unsplash.com/photo-1501785888041-af3ef285b470?auto=format&fit=crop&w=600&h=400&q=70 600w, https://images.unsplash.com/photo-1501785888041-af3ef285b470?auto=format&fit=crop&w=900&h=600&q=70 900w, https://images.unsplash.com/photo-1501785888041-af3ef285b470?auto=format&fit=crop&w=1200&h=800&q=70 1200w" sizes="(min-width: 1040px) 980px, calc(100vw - 32px)" width="1200" height="800" loading="lazy" alt="After: a wooden rowing boat on a bright turquoise lake below sunlit limestone peaks, with rich colour and contrast">
<div class="ic-before"><img src="https://images.unsplash.com/photo-1501785888041-af3ef285b470?auto=format&fit=crop&w=900&h=600&q=70" srcset="https://images.unsplash.com/photo-1501785888041-af3ef285b470?auto=format&fit=crop&w=600&h=400&q=70 600w, https://images.unsplash.com/photo-1501785888041-af3ef285b470?auto=format&fit=crop&w=900&h=600&q=70 900w, https://images.unsplash.com/photo-1501785888041-af3ef285b470?auto=format&fit=crop&w=1200&h=800&q=70 1200w" sizes="(min-width: 1040px) 980px, calc(100vw - 32px)" width="1200" height="800" class="w-100 h-100" loading="lazy" alt="Before: the same lake scene straight from the camera, flat and washed out"></div>
<div class="pe-none"><span class="badge rounded-pill position-absolute ic-tag-b" aria-hidden="true">Before</span><span class="badge rounded-pill position-absolute ic-tag-a" aria-hidden="true">After</span></div>
<input class="form-range" type="range" min="0" max="100" value="50" aria-label="Compare the unedited and graded photo of the lake" aria-valuetext="50% after" data-compare-range>
<span class="ic-handle" aria-hidden="true"><span class="ic-knob"><svg viewBox="0 0 24 24"><path d="M9 6l-6 6 6 6M15 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></span></span>
</div>
<figcaption class="figure-caption d-flex flex-wrap justify-content-between column-gap-4 row-gap-1 mt-3"><strong class="fw-semibold text-body">Lago di Braies, colour grade</strong><span>Shot by Mira Kessler · RAW to final in 6 adjustments</span></figcaption>
</figure></div>
<div><span class="lbl">Card with pill handle and edit list</span>
<article class="card ic-card border-0">
<div class="ic ratio ratio-4x3 rounded-3" data-compare>
<img src="https://images.unsplash.com/photo-1470770841072-f978cf4d019e?auto=format&fit=crop&w=800&h=600&q=70" srcset="https://images.unsplash.com/photo-1470770841072-f978cf4d019e?auto=format&fit=crop&w=480&h=360&q=70 480w, https://images.unsplash.com/photo-1470770841072-f978cf4d019e?auto=format&fit=crop&w=800&h=600&q=70 800w, https://images.unsplash.com/photo-1470770841072-f978cf4d019e?auto=format&fit=crop&w=1240&h=930&q=70 1240w" sizes="(min-width: 680px) 592px, calc(100vw - 60px)" width="1240" height="930" loading="lazy" alt="Graded: a wooden boathouse on stilts in a green lake beneath cloud-wrapped mountains, in warm natural colour">
<div class="ic-before"><img src="https://images.unsplash.com/photo-1470770841072-f978cf4d019e?auto=format&fit=crop&w=800&h=600&q=70" srcset="https://images.unsplash.com/photo-1470770841072-f978cf4d019e?auto=format&fit=crop&w=480&h=360&q=70 480w, https://images.unsplash.com/photo-1470770841072-f978cf4d019e?auto=format&fit=crop&w=800&h=600&q=70 800w, https://images.unsplash.com/photo-1470770841072-f978cf4d019e?auto=format&fit=crop&w=1240&h=930&q=70 1240w" sizes="(min-width: 680px) 592px, calc(100vw - 60px)" width="1240" height="930" class="w-100 h-100" loading="lazy" alt="Original: the same boathouse photo before editing, dull and greyish" style="--ic-before:brightness(.96) contrast(.82) saturate(.45) sepia(.35)"></div>
<div class="pe-none"><span class="badge rounded-pill position-absolute ic-tag-b" aria-hidden="true">Original</span><span class="badge rounded-pill position-absolute ic-tag-a" aria-hidden="true">Graded</span></div>
<input class="form-range" type="range" min="0" max="100" value="50" aria-label="Compare the original and graded photo of the boathouse" aria-valuetext="50% after" data-compare-range>
<span class="ic-handle" aria-hidden="true"><span class="ic-knob"><svg viewBox="0 0 24 24"><path d="M9 7l-5 5 5 5M15 7l5 5-5 5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></span></span>
</div>
<div class="card-body d-flex flex-wrap align-items-center justify-content-between gap-3 px-2 pt-3 pb-1">
<div><h3 class="ic-card-title fw-semibold mb-0">Boathouse at Braies</h3><p class="ic-card-meta mb-0 mt-1">Preset “Alpine Morning” by Studio Kessler</p></div>
<ul class="list-unstyled d-flex flex-wrap gap-2 mb-0" aria-label="Adjustments"><li class="badge rounded-pill text-bg-light">Exposure +0.4</li><li class="badge rounded-pill text-bg-light">Contrast +18</li><li class="badge rounded-pill text-bg-light">Vibrance +32</li></ul>
</div>
</article></div>
// Before/after slider: the range input is the control. This mirrors its value into --v
// (which moves the divider and clip) and gives screen readers a readable value.
document.querySelectorAll('[data-compare]').forEach((root) => {
const range = root.querySelector('[data-compare-range]');
if (!range) return;
const sync = () => {
root.style.setProperty('--v', range.value);
range.setAttribute('aria-valuetext', `${100 - range.value}% after`);
};
range.addEventListener('input', sync);
sync();
});
/* The range value (0–100) is mirrored into --v; --x is the divider position, kept centred on the native thumb. */
@utility compare { --x: calc(var(--thumb) / 2 + (100% - var(--thumb)) * var(--v) / 100); }
@utility clip-before { clip-path: inset(0 calc(100% - var(--x)) 0 0); }
@utility compare-range {
-webkit-appearance: none; appearance: none; background: none;
&::-webkit-slider-runnable-track { height: 100%; }
&::-webkit-slider-thumb { -webkit-appearance: none; width: var(--thumb); height: 100%; }
&::-moz-range-thumb { width: var(--thumb); height: 100%; border: 0; }
}
JavaScript
// Before/after slider: the range input is the control. This mirrors its value into --v
// (which moves the divider and clip) and gives screen readers a readable value.
document.querySelectorAll('[data-compare]').forEach((root) => {
const range = root.querySelector('[data-compare-range]');
if (!range) return;
const sync = () => {
root.style.setProperty('--v', range.value);
range.setAttribute('aria-valuetext', `${100 - range.value}% after`);
};
range.addEventListener('input', sync);
sync();
});
About this set
Two image comparison sliders for the most common job: showing an unedited photo and the finished one in the same frame. Drag slider with before and after labels is a full-width 3:2 lake photo with a white divider, a round arrow handle and Before and After pills that fade out as the divider reaches them, with a caption line underneath. Card with pill handle and edit list puts a 4:3 comparison in a white card with a tall pill handle, a title, the preset name and three adjustment chips, for portfolio and preset pages. Both are built on a real input type range stretched invisibly over the photos. Its value, from 0 to 100, is copied into a –v custom property by a few lines of JavaScript, and –v drives both the clip-path inset that crops the before layer and the position of the divider. Because the control is a native range, it works with a mouse, a touch drag, the arrow keys, Home and End, and screen readers, with no pointer-event code. The before photo is the same Unsplash image as the after with a CSS filter, so the two layers always line up and the browser downloads one file. Both images use srcset, sizes, width and height, so nothing jumps while they load.
What’s included
Native input type range as the control: keyboard, touch and screen readers work without extra code
clip-path inset driven by a --v custom property
Divider kept centred on the native thumb, so the handle follows the pointer exactly
Before and After labels fade out as the divider reaches them
srcset, sizes, width and height on every photo to avoid layout shift
Accessibility
The range input carries an aria-label that names both photos and an aria-valuetext such as 62% after, updated as it moves; both photos have alt text that says which version they are. Arrow keys move the divider by one step and Home and End jump to the ends. The handle shows a 3px blue outline with a white ring when the range has keyboard focus, and the label fade and handle hover are switched off under prefers-reduced-motion.
Customise it
Change --thumb, --ic-radius, --ic-line, --ic-focus and --ic-before (the filter that fakes the unedited photo) on .ic. In Bootstrap the stage is a .ratio and the labels are .badge; in Tailwind edit aspect-[3/2], the brightness-, contrast- and saturate- utilities on the before image and the [--thumb] value.