<div><span class="lbl">Scroll-driven reveal, no JavaScript</span>
<figure class="sr">
<div class="ic">
<img src="https://images.unsplash.com/photo-1600585154340-be6161a56a0c?auto=format&fit=crop&w=900&h=600&q=70" srcset="https://images.unsplash.com/photo-1600585154340-be6161a56a0c?auto=format&fit=crop&w=600&h=400&q=70 600w, https://images.unsplash.com/photo-1600585154340-be6161a56a0c?auto=format&fit=crop&w=900&h=600&q=70 900w, https://images.unsplash.com/photo-1600585154340-be6161a56a0c?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 two-storey timber and glass house glowing warmly from inside at dusk, with a lawn and a large tree">
<div class="ic-before"><img src="https://images.unsplash.com/photo-1600585154340-be6161a56a0c?auto=format&fit=crop&w=900&h=600&q=70" srcset="https://images.unsplash.com/photo-1600585154340-be6161a56a0c?auto=format&fit=crop&w=600&h=400&q=70 600w, https://images.unsplash.com/photo-1600585154340-be6161a56a0c?auto=format&fit=crop&w=900&h=600&q=70 900w, https://images.unsplash.com/photo-1600585154340-be6161a56a0c?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 house in flat, dim evening light"></div>
<span class="ic-tag ic-tag-b" aria-hidden="true">Before</span><span class="ic-tag ic-tag-a" aria-hidden="true">Twilight edit</span>
<span class="ic-handle" aria-hidden="true"></span>
</div>
<figcaption class="ic-cap"><strong>14 Cedar Rise, listing photo</strong><span>Scroll to reveal the twilight edit</span></figcaption>
</figure></div>
<div><span class="lbl">@property intro sweep, then drag</span>
<figure class="sw">
<div class="ic" data-compare data-sweep>
<img src="https://images.unsplash.com/photo-1500964757637-c85e8a162699?auto=format&fit=crop&w=900&h=600&q=70" srcset="https://images.unsplash.com/photo-1500964757637-c85e8a162699?auto=format&fit=crop&w=600&h=400&q=70 600w, https://images.unsplash.com/photo-1500964757637-c85e8a162699?auto=format&fit=crop&w=900&h=600&q=70 900w, https://images.unsplash.com/photo-1500964757637-c85e8a162699?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: pastel layers of mountain ridges under an orange and blue sky">
<div class="ic-before"><img src="https://images.unsplash.com/photo-1500964757637-c85e8a162699?auto=format&fit=crop&w=900&h=600&q=70" srcset="https://images.unsplash.com/photo-1500964757637-c85e8a162699?auto=format&fit=crop&w=600&h=400&q=70 600w, https://images.unsplash.com/photo-1500964757637-c85e8a162699?auto=format&fit=crop&w=900&h=600&q=70 900w, https://images.unsplash.com/photo-1500964757637-c85e8a162699?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 ridges in muted, almost grey tones"></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 muted and graded photo of the ridges" 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="ic-cap"><strong>Ridges at first light</strong><span>The handle sweeps once when it comes into view</span></figcaption>
</figure></div>
<div><span class="lbl">Container queries: wide and narrow container</span>
<div class="cq-grid">
<figure class="cq">
<div class="ic" data-compare>
<img src="https://images.unsplash.com/photo-1497436072909-60f360e1d4b1?auto=format&fit=crop&w=640&h=427&q=70" srcset="https://images.unsplash.com/photo-1497436072909-60f360e1d4b1?auto=format&fit=crop&w=400&h=267&q=70 400w, https://images.unsplash.com/photo-1497436072909-60f360e1d4b1?auto=format&fit=crop&w=640&h=427&q=70 640w, https://images.unsplash.com/photo-1497436072909-60f360e1d4b1?auto=format&fit=crop&w=960&h=640&q=70 960w" sizes="(min-width: 1040px) 645px, (min-width: 768px) 66vw, calc(100vw - 32px)" width="960" height="640" loading="lazy" alt="After: aerial view of dense green forest meeting a bright turquoise lake along a straight edge">
<div class="ic-before"><img src="https://images.unsplash.com/photo-1497436072909-60f360e1d4b1?auto=format&fit=crop&w=640&h=427&q=70" srcset="https://images.unsplash.com/photo-1497436072909-60f360e1d4b1?auto=format&fit=crop&w=400&h=267&q=70 400w, https://images.unsplash.com/photo-1497436072909-60f360e1d4b1?auto=format&fit=crop&w=640&h=427&q=70 640w, https://images.unsplash.com/photo-1497436072909-60f360e1d4b1?auto=format&fit=crop&w=960&h=640&q=70 960w" sizes="(min-width: 1040px) 645px, (min-width: 768px) 66vw, calc(100vw - 32px)" width="960" height="640" loading="lazy" alt="Before: the same forest and lake, grey and flat"></div>
<span class="ic-tag ic-tag-b" aria-hidden="true">Before <span class="ic-tag-long">· drone file</span></span><span class="ic-tag ic-tag-a" aria-hidden="true">After <span class="ic-tag-long">· graded</span></span>
<input class="ic-range" type="range" min="0" max="100" value="50" aria-label="Compare the flat and graded aerial photo, wide version" 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="cq-cap"><strong>Lake edge, aerial</strong><span>Caption over the photo when wide, below it when narrow</span></figcaption>
</figure>
<figure class="cq">
<div class="ic" data-compare>
<img src="https://images.unsplash.com/photo-1497436072909-60f360e1d4b1?auto=format&fit=crop&w=640&h=427&q=70" srcset="https://images.unsplash.com/photo-1497436072909-60f360e1d4b1?auto=format&fit=crop&w=400&h=267&q=70 400w, https://images.unsplash.com/photo-1497436072909-60f360e1d4b1?auto=format&fit=crop&w=640&h=427&q=70 640w, https://images.unsplash.com/photo-1497436072909-60f360e1d4b1?auto=format&fit=crop&w=960&h=640&q=70 960w" sizes="(min-width: 1040px) 315px, (min-width: 768px) 33vw, calc(100vw - 32px)" width="960" height="640" loading="lazy" alt="After: aerial view of dense green forest meeting a bright turquoise lake along a straight edge">
<div class="ic-before"><img src="https://images.unsplash.com/photo-1497436072909-60f360e1d4b1?auto=format&fit=crop&w=640&h=427&q=70" srcset="https://images.unsplash.com/photo-1497436072909-60f360e1d4b1?auto=format&fit=crop&w=400&h=267&q=70 400w, https://images.unsplash.com/photo-1497436072909-60f360e1d4b1?auto=format&fit=crop&w=640&h=427&q=70 640w, https://images.unsplash.com/photo-1497436072909-60f360e1d4b1?auto=format&fit=crop&w=960&h=640&q=70 960w" sizes="(min-width: 1040px) 315px, (min-width: 768px) 33vw, calc(100vw - 32px)" width="960" height="640" loading="lazy" alt="Before: the same forest and lake, grey and flat"></div>
<span class="ic-tag ic-tag-b" aria-hidden="true">Before <span class="ic-tag-long">· drone file</span></span><span class="ic-tag ic-tag-a" aria-hidden="true">After <span class="ic-tag-long">· graded</span></span>
<input class="ic-range" type="range" min="0" max="100" value="50" aria-label="Compare the flat and graded aerial photo, narrow version" 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="cq-cap"><strong>Lake edge, aerial</strong><span>Caption over the photo when wide, below it when narrow</span></figcaption>
</figure>
</div></div>
CSS
/* --v is registered with @property so it can be animated (by scroll or by a keyframe sweep). */
@property --v{syntax:"<number>";inherits:true;initial-value:50}
.ic{--v:50;--thumb:44px;--x:calc(var(--thumb) / 2 + (100% - var(--thumb)) * var(--v) / 100);
position:relative;display:block;aspect-ratio:3/2;overflow:hidden;border-radius:16px;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-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}
.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:#fff;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)}
.ic-knob svg{width:22px;height:22px}
.ic-range:focus-visible ~ .ic-handle .ic-knob{outline:3px solid #1d4ed8;outline-offset:3px;box-shadow:0 0 0 3px #fff}
.ic-cap{margin-top:14px;font-size:.92rem}
.ic-cap strong{display:block;font-weight:600}
.ic-cap span{display:block;margin-top:3px;color:#5f5a52}
/* 1. Scroll-driven reveal: --v runs from 100 (all before) to 0 (all after) as the photo crosses the viewport.
No JavaScript. Without scroll-driven animations, or with reduced motion, it stays a 50/50 split. */
.sr .ic-before img{filter:brightness(.55) contrast(.9) saturate(.45)}
@supports (animation-timeline:view()){
@media (prefers-reduced-motion:no-preference){
.sr .ic{animation:sr-reveal linear both;animation-timeline:view();animation-range:entry 30% cover 65%}
}
}
@keyframes sr-reveal{from{--v:100}to{--v:0}}
/* 2. Intro sweep: when the slider first scrolls into view, --v sweeps across once to show it can be dragged.
The range input then takes over. Off under reduced motion. */
.sw .ic-before img{filter:brightness(.95) contrast(.85) grayscale(.6)}
@media (prefers-reduced-motion:no-preference){
.sw .ic.is-sweeping{animation:sw-sweep 2.2s cubic-bezier(.45,0,.25,1)}
.sw .ic.is-sweeping .ic-knob{animation:sw-pulse 2.2s ease}
}
@keyframes sw-sweep{0%{--v:50}30%{--v:12}70%{--v:88}100%{--v:50}}
@keyframes sw-pulse{0%,100%{scale:1}50%{scale:1.12}}
/* 3. Container queries: the same component adapts to the width of its container, not the viewport */
.cq-grid{display:grid;gap:24px;align-items:start}
@media (min-width:768px){.cq-grid{grid-template-columns:minmax(0,2fr) minmax(0,1fr)}}
.cq{position:relative;container-type:inline-size}
.cq .ic-before img{filter:brightness(.88) contrast(.8) saturate(.35)}
.cq .ic-tag{top:4cqi;font-size:clamp(.62rem,2.2cqi,.76rem)}
.cq .ic-tag-b{left:4cqi}.cq .ic-tag-a{right:4cqi}
.cq-cap{position:absolute;left:0;right:0;bottom:0;z-index:3;padding:40px 18px 14px;border-radius:0 0 16px 16px;background:linear-gradient(to top,rgba(10,12,16,.85),rgba(10,12,16,0));color:#fff;pointer-events:none}
.cq-cap strong{display:block;font-size:1.05rem}
.cq-cap span{display:block;margin-top:2px;font-size:.84rem;color:#e6e4df}
@container (max-width:380px){
.cq .ic{aspect-ratio:4/5}
.cq .ic-knob{width:34px;height:34px}
.cq .ic-knob svg{width:16px;height:16px}
.cq .ic-tag-long{display:none}
.cq-cap{position:static;padding:12px 2px 0;background:none;color:#16181d}
.cq-cap span{color:#5f5a52}
}
JavaScript
// Range-driven compare: the range value goes to --v. [data-sweep] plays the CSS intro sweep once,
// the first time the slider is mostly in view; touching the slider stops it.
document.querySelectorAll('[data-compare]').forEach((root) => {
const range = root.querySelector('[data-compare-range]');
if (!range) return;
const sync = () => {
root.classList.remove('is-sweeping');
root.style.setProperty('--v', range.value);
range.setAttribute('aria-valuetext', `${100 - range.value}% after`);
};
range.addEventListener('input', sync);
sync();
if (!root.hasAttribute('data-sweep') || !('IntersectionObserver' in window)) return;
const io = new IntersectionObserver(([entry]) => {
if (!entry.isIntersecting) return;
root.classList.add('is-sweeping');
io.disconnect();
}, { threshold: 0.6 });
io.observe(root);
root.addEventListener('animationend', (e) => { if (e.target === root) root.classList.remove('is-sweeping'); });
});
Bootstrap 5 code
Requires: Bootstrap 5.3.8 CSS
HTML
<div><span class="lbl">Scroll-driven reveal, no JavaScript</span>
<figure class="figure d-block m-0 sr">
<div class="ic">
<img src="https://images.unsplash.com/photo-1600585154340-be6161a56a0c?auto=format&fit=crop&w=900&h=600&q=70" srcset="https://images.unsplash.com/photo-1600585154340-be6161a56a0c?auto=format&fit=crop&w=600&h=400&q=70 600w, https://images.unsplash.com/photo-1600585154340-be6161a56a0c?auto=format&fit=crop&w=900&h=600&q=70 900w, https://images.unsplash.com/photo-1600585154340-be6161a56a0c?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 two-storey timber and glass house glowing warmly from inside at dusk, with a lawn and a large tree">
<div class="ic-before"><img src="https://images.unsplash.com/photo-1600585154340-be6161a56a0c?auto=format&fit=crop&w=900&h=600&q=70" srcset="https://images.unsplash.com/photo-1600585154340-be6161a56a0c?auto=format&fit=crop&w=600&h=400&q=70 600w, https://images.unsplash.com/photo-1600585154340-be6161a56a0c?auto=format&fit=crop&w=900&h=600&q=70 900w, https://images.unsplash.com/photo-1600585154340-be6161a56a0c?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 house in flat, dim evening light"></div>
<span class="badge rounded-pill ic-tag ic-tag-b" aria-hidden="true">Before</span><span class="badge rounded-pill ic-tag ic-tag-a" aria-hidden="true">Twilight edit</span>
<span class="ic-handle" aria-hidden="true"></span>
</div>
<figcaption class="figure-caption ic-cap"><strong class="text-body">14 Cedar Rise, listing photo</strong><span>Scroll to reveal the twilight edit</span></figcaption>
</figure></div>
<div><span class="lbl">@property intro sweep, then drag</span>
<figure class="figure d-block m-0 sw">
<div class="ic" data-compare data-sweep>
<img src="https://images.unsplash.com/photo-1500964757637-c85e8a162699?auto=format&fit=crop&w=900&h=600&q=70" srcset="https://images.unsplash.com/photo-1500964757637-c85e8a162699?auto=format&fit=crop&w=600&h=400&q=70 600w, https://images.unsplash.com/photo-1500964757637-c85e8a162699?auto=format&fit=crop&w=900&h=600&q=70 900w, https://images.unsplash.com/photo-1500964757637-c85e8a162699?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: pastel layers of mountain ridges under an orange and blue sky">
<div class="ic-before"><img src="https://images.unsplash.com/photo-1500964757637-c85e8a162699?auto=format&fit=crop&w=900&h=600&q=70" srcset="https://images.unsplash.com/photo-1500964757637-c85e8a162699?auto=format&fit=crop&w=600&h=400&q=70 600w, https://images.unsplash.com/photo-1500964757637-c85e8a162699?auto=format&fit=crop&w=900&h=600&q=70 900w, https://images.unsplash.com/photo-1500964757637-c85e8a162699?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 ridges in muted, almost grey tones"></div>
<span class="badge rounded-pill ic-tag ic-tag-b" aria-hidden="true">Before</span><span class="badge rounded-pill 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 muted and graded photo of the ridges" 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 ic-cap"><strong class="text-body">Ridges at first light</strong><span>The handle sweeps once when it comes into view</span></figcaption>
</figure></div>
<div><span class="lbl">Container queries: wide and narrow container</span>
<div class="row g-4 align-items-start">
<div class="col-md-8"><figure class="cq figure d-block m-0">
<div class="ic" data-compare>
<img src="https://images.unsplash.com/photo-1497436072909-60f360e1d4b1?auto=format&fit=crop&w=640&h=427&q=70" srcset="https://images.unsplash.com/photo-1497436072909-60f360e1d4b1?auto=format&fit=crop&w=400&h=267&q=70 400w, https://images.unsplash.com/photo-1497436072909-60f360e1d4b1?auto=format&fit=crop&w=640&h=427&q=70 640w, https://images.unsplash.com/photo-1497436072909-60f360e1d4b1?auto=format&fit=crop&w=960&h=640&q=70 960w" sizes="(min-width: 1040px) 645px, (min-width: 768px) 64vw, calc(100vw - 32px)" width="960" height="640" loading="lazy" alt="After: aerial view of dense green forest meeting a bright turquoise lake along a straight edge">
<div class="ic-before"><img src="https://images.unsplash.com/photo-1497436072909-60f360e1d4b1?auto=format&fit=crop&w=640&h=427&q=70" srcset="https://images.unsplash.com/photo-1497436072909-60f360e1d4b1?auto=format&fit=crop&w=400&h=267&q=70 400w, https://images.unsplash.com/photo-1497436072909-60f360e1d4b1?auto=format&fit=crop&w=640&h=427&q=70 640w, https://images.unsplash.com/photo-1497436072909-60f360e1d4b1?auto=format&fit=crop&w=960&h=640&q=70 960w" sizes="(min-width: 1040px) 645px, (min-width: 768px) 64vw, calc(100vw - 32px)" width="960" height="640" loading="lazy" alt="Before: the same forest and lake, grey and flat"></div>
<span class="badge rounded-pill ic-tag ic-tag-b" aria-hidden="true">Before <span class="ic-tag-long fw-bold">· drone file</span></span><span class="badge rounded-pill ic-tag ic-tag-a" aria-hidden="true">After <span class="ic-tag-long fw-bold">· graded</span></span>
<input class="ic-range" type="range" min="0" max="100" value="50" aria-label="Compare the flat and graded aerial photo, wide version" 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="cq-cap"><strong>Lake edge, aerial</strong><span>Caption over the photo when wide, below it when narrow</span></figcaption>
</figure></div>
<div class="col-md-4"><figure class="cq figure d-block m-0">
<div class="ic" data-compare>
<img src="https://images.unsplash.com/photo-1497436072909-60f360e1d4b1?auto=format&fit=crop&w=640&h=427&q=70" srcset="https://images.unsplash.com/photo-1497436072909-60f360e1d4b1?auto=format&fit=crop&w=400&h=267&q=70 400w, https://images.unsplash.com/photo-1497436072909-60f360e1d4b1?auto=format&fit=crop&w=640&h=427&q=70 640w, https://images.unsplash.com/photo-1497436072909-60f360e1d4b1?auto=format&fit=crop&w=960&h=640&q=70 960w" sizes="(min-width: 1040px) 315px, (min-width: 768px) 33vw, calc(100vw - 32px)" width="960" height="640" loading="lazy" alt="After: aerial view of dense green forest meeting a bright turquoise lake along a straight edge">
<div class="ic-before"><img src="https://images.unsplash.com/photo-1497436072909-60f360e1d4b1?auto=format&fit=crop&w=640&h=427&q=70" srcset="https://images.unsplash.com/photo-1497436072909-60f360e1d4b1?auto=format&fit=crop&w=400&h=267&q=70 400w, https://images.unsplash.com/photo-1497436072909-60f360e1d4b1?auto=format&fit=crop&w=640&h=427&q=70 640w, https://images.unsplash.com/photo-1497436072909-60f360e1d4b1?auto=format&fit=crop&w=960&h=640&q=70 960w" sizes="(min-width: 1040px) 315px, (min-width: 768px) 33vw, calc(100vw - 32px)" width="960" height="640" loading="lazy" alt="Before: the same forest and lake, grey and flat"></div>
<span class="badge rounded-pill ic-tag ic-tag-b" aria-hidden="true">Before <span class="ic-tag-long fw-bold">· drone file</span></span><span class="badge rounded-pill ic-tag ic-tag-a" aria-hidden="true">After <span class="ic-tag-long fw-bold">· graded</span></span>
<input class="ic-range" type="range" min="0" max="100" value="50" aria-label="Compare the flat and graded aerial photo, narrow version" 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="cq-cap"><strong>Lake edge, aerial</strong><span>Caption over the photo when wide, below it when narrow</span></figcaption>
</figure></div>
</div></div>
CSS
/* Bootstrap supplies the grid (.row, .col-md-*), .figure and .badge; the compare itself is modern CSS.
--v is registered with @property so it can be animated (by scroll or by a keyframe sweep). */
@property --v{syntax:"<number>";inherits:true;initial-value:50}
.ic{--v:50;--thumb:44px;--x:calc(var(--thumb) / 2 + (100% - var(--thumb)) * var(--v) / 100);
position:relative;display:block;aspect-ratio:3/2;overflow:hidden;border-radius:16px;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-tag{position:absolute;top:14px;padding:5px 11px;background:rgba(15,17,21,.78);font-size:.72rem;line-height:1.2;letter-spacing:.08em;text-transform:uppercase;pointer-events:none}
.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:#fff;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)}
.ic-knob svg{width:22px;height:22px}
.ic-range:focus-visible ~ .ic-handle .ic-knob{outline:3px solid #1d4ed8;outline-offset:3px;box-shadow:0 0 0 3px #fff}
.ic-cap{margin-top:14px;font-size:.92rem;color:#5f5a52}
.ic-cap strong{display:block;font-weight:600}
.ic-cap span{display:block;margin-top:3px;color:#5f5a52}
/* 1. Scroll-driven reveal: --v runs from 100 (all before) to 0 (all after) as the photo crosses the viewport.
No JavaScript. Without scroll-driven animations, or with reduced motion, it stays a 50/50 split. */
.sr .ic-before img{filter:brightness(.55) contrast(.9) saturate(.45)}
@supports (animation-timeline:view()){
@media (prefers-reduced-motion:no-preference){
.sr .ic{animation:sr-reveal linear both;animation-timeline:view();animation-range:entry 30% cover 65%}
}
}
@keyframes sr-reveal{from{--v:100}to{--v:0}}
/* 2. Intro sweep: when the slider first scrolls into view, --v sweeps across once to show it can be dragged.
The range input then takes over. Off under reduced motion. */
.sw .ic-before img{filter:brightness(.95) contrast(.85) grayscale(.6)}
@media (prefers-reduced-motion:no-preference){
.sw .ic.is-sweeping{animation:sw-sweep 2.2s cubic-bezier(.45,0,.25,1)}
.sw .ic.is-sweeping .ic-knob{animation:sw-pulse 2.2s ease}
}
@keyframes sw-sweep{0%{--v:50}30%{--v:12}70%{--v:88}100%{--v:50}}
@keyframes sw-pulse{0%,100%{scale:1}50%{scale:1.12}}
/* 3. Container queries: the same component adapts to the width of its container, not the viewport */
.cq{position:relative;container-type:inline-size}
.cq .ic-before img{filter:brightness(.88) contrast(.8) saturate(.35)}
.cq .ic-tag{top:4cqi;font-size:clamp(.62rem,2.2cqi,.76rem)}
.cq .ic-tag-b{left:4cqi}.cq .ic-tag-a{right:4cqi}
.cq-cap{position:absolute;left:0;right:0;bottom:0;z-index:3;padding:40px 18px 14px;border-radius:0 0 16px 16px;background:linear-gradient(to top,rgba(10,12,16,.85),rgba(10,12,16,0));color:#fff;pointer-events:none}
.cq-cap strong{display:block;font-size:1.05rem}
.cq-cap span{display:block;margin-top:2px;font-size:.84rem;color:#e6e4df}
@container (max-width:380px){
.cq .ic{aspect-ratio:4/5}
.cq .ic-knob{width:34px;height:34px}
.cq .ic-knob svg{width:16px;height:16px}
.cq .ic-tag-long{display:none}
.cq-cap{position:static;padding:12px 2px 0;background:none;color:#16181d}
.cq-cap span{color:#5f5a52}
}
JavaScript
// Range-driven compare: the range value goes to --v. [data-sweep] plays the CSS intro sweep once,
// the first time the slider is mostly in view; touching the slider stops it.
document.querySelectorAll('[data-compare]').forEach((root) => {
const range = root.querySelector('[data-compare-range]');
if (!range) return;
const sync = () => {
root.classList.remove('is-sweeping');
root.style.setProperty('--v', range.value);
range.setAttribute('aria-valuetext', `${100 - range.value}% after`);
};
range.addEventListener('input', sync);
sync();
if (!root.hasAttribute('data-sweep') || !('IntersectionObserver' in window)) return;
const io = new IntersectionObserver(([entry]) => {
if (!entry.isIntersecting) return;
root.classList.add('is-sweeping');
io.disconnect();
}, { threshold: 0.6 });
io.observe(root);
root.addEventListener('animationend', (e) => { if (e.target === root) root.classList.remove('is-sweeping'); });
});
// Range-driven compare: the range value goes to --v. [data-sweep] plays the CSS intro sweep once,
// the first time the slider is mostly in view; touching the slider stops it.
document.querySelectorAll('[data-compare]').forEach((root) => {
const range = root.querySelector('[data-compare-range]');
if (!range) return;
const sync = () => {
root.classList.remove('is-sweeping');
root.style.setProperty('--v', range.value);
range.setAttribute('aria-valuetext', `${100 - range.value}% after`);
};
range.addEventListener('input', sync);
sync();
if (!root.hasAttribute('data-sweep') || !('IntersectionObserver' in window)) return;
const io = new IntersectionObserver(([entry]) => {
if (!entry.isIntersecting) return;
root.classList.add('is-sweeping');
io.disconnect();
}, { threshold: 0.6 });
io.observe(root);
root.addEventListener('animationend', (e) => { if (e.target === root) root.classList.remove('is-sweeping'); });
});
About this set
Three things image comparison plugins usually do with JavaScript, done with newer CSS. Scroll-driven reveal registers –v with @property and animates it from 100 to 0 on a view() timeline, so a house photo turns from a flat dusk shot into a twilight edit as it scrolls through the viewport, with no script at all. Where scroll-driven animations are not supported, or the visitor prefers reduced motion, it stays a still 50/50 split. Intro sweep, then drag uses the same registered property for a keyframe sweep: the first time the slider is mostly in view the handle swings left and right once to show that it can be dragged, then the native range input takes over, and touching it stops the sweep straight away. Container queries places the same comparison in a wide and a narrow column: in the narrow container it switches to a 4:5 crop, a smaller handle and short labels, and its caption moves from over the photo to below it, all decided by the width of the container rather than the viewport. Use these on landing pages, listings and editorial layouts.
What’s included
Scroll-driven reveal with animation-timeline: view() and no JavaScript
@property makes the --v custom property animatable
One-time intro sweep that stops as soon as the slider is used
Container queries adapt crop, handle, labels and caption to the column
Static 50/50 fallback when a feature is missing or motion is reduced
Accessibility
The sweep and container query sliders are native range inputs with an aria-label and aria-valuetext; the scroll-driven reveal has no control, so both photos carry alt text describing each version. Both animations run only under prefers-reduced-motion: no-preference, and the sweep plays once and ends at the middle. Handles show a blue outline on keyboard focus.
Customise it
Change animation-range on .sr .ic to start or finish the reveal sooner, the sw-sweep keyframes for the intro, and the 380px container breakpoint. In Tailwind these are the scroll-reveal utility, the animate-[sw-sweep] class and the @max-[380px]: variants; Bootstrap uses the same CSS with .row and .col-md-* for the columns.