Open demo

CSS code

HTML
<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'); });
});

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<div><span class="mb-4 block text-[.72rem] tracking-[.12em] text-[#5f5a52] uppercase">Scroll-driven reveal, no JavaScript</span>
<figure>
  <div class="compare relative isolate block aspect-[3/2] overflow-hidden rounded-2xl bg-[#d8d3ca] select-none [--thumb:44px] scroll-reveal [--v:50]">
    <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" class="pointer-events-none absolute inset-0 block size-full object-cover" 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="clip-before absolute inset-0"><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" class="pointer-events-none absolute inset-0 block size-full object-cover brightness-55 contrast-90 saturate-45" loading="lazy" alt="Before: the same house in flat, dim evening light"></div>
    <span class="pointer-events-none absolute rounded-full bg-[rgba(15,17,21,.78)] px-[11px] py-[5px] text-[.72rem] font-bold tracking-[.08em] text-white uppercase top-3.5 left-3.5 opacity-[clamp(0,calc((var(--v)_-_14)/8),1)]" aria-hidden="true">Before</span><span class="pointer-events-none absolute rounded-full bg-[rgba(15,17,21,.78)] px-[11px] py-[5px] text-[.72rem] font-bold tracking-[.08em] text-white uppercase top-3.5 right-3.5 opacity-[clamp(0,calc((86_-_var(--v))/8),1)]" aria-hidden="true">Twilight edit</span>
    <span class="pointer-events-none absolute inset-y-0 left-(--x) z-1 w-[3px] -translate-x-1/2 bg-white shadow-[0_0_10px_rgba(0,0,0,.35)]" data-handle aria-hidden="true"></span>
  </div>
  <figcaption class="mt-3.5 text-[.92rem]"><strong class="block font-semibold">14 Cedar Rise, listing photo</strong><span class="mt-[3px] block text-[#5f5a52]">Scroll to reveal the twilight edit</span></figcaption>
</figure></div>

<div><span class="mb-4 block text-[.72rem] tracking-[.12em] text-[#5f5a52] uppercase">@property intro sweep, then drag</span>
<figure>
  <div class="compare relative isolate block aspect-[3/2] overflow-hidden rounded-2xl bg-[#d8d3ca] select-none [--thumb:44px] group [--v:50] motion-safe:[&.is-sweeping]:animate-[sw-sweep_2.2s_cubic-bezier(.45,0,.25,1)]" 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" class="pointer-events-none absolute inset-0 block size-full object-cover" loading="lazy" alt="After: pastel layers of mountain ridges under an orange and blue sky">
    <div class="clip-before absolute inset-0"><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" class="pointer-events-none absolute inset-0 block size-full object-cover brightness-95 contrast-85 grayscale-60" loading="lazy" alt="Before: the same ridges in muted, almost grey tones"></div>
    <span class="pointer-events-none absolute rounded-full bg-[rgba(15,17,21,.78)] px-[11px] py-[5px] text-[.72rem] font-bold tracking-[.08em] text-white uppercase top-3.5 left-3.5 opacity-[clamp(0,calc((var(--v)_-_14)/8),1)]" aria-hidden="true">Before</span><span class="pointer-events-none absolute rounded-full bg-[rgba(15,17,21,.78)] px-[11px] py-[5px] text-[.72rem] font-bold tracking-[.08em] text-white uppercase top-3.5 right-3.5 opacity-[clamp(0,calc((86_-_var(--v))/8),1)]" aria-hidden="true">After</span>
    <input class="compare-range peer absolute inset-0 z-2 m-0 size-full cursor-ew-resize opacity-0" 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="pointer-events-none absolute inset-y-0 left-(--x) z-1 w-[3px] -translate-x-1/2 bg-white shadow-[0_0_10px_rgba(0,0,0,.35)]" data-handle aria-hidden="true"></span>
    <span class="pointer-events-none absolute top-1/2 left-(--x) z-1 grid size-[46px] -translate-1/2 place-items-center rounded-full bg-white text-[#16181d] shadow-[0_4px_16px_rgba(0,0,0,.3)] peer-focus-visible:shadow-[0_0_0_3px_#fff] peer-focus-visible:outline-3 peer-focus-visible:outline-offset-3 peer-focus-visible:outline-[#1d4ed8] motion-safe:group-[.is-sweeping]:animate-[sw-pulse_2.2s_ease]" aria-hidden="true"><svg class="size-[22px] @max-[380px]:size-4" 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>
  </div>
  <figcaption class="mt-3.5 text-[.92rem]"><strong class="block font-semibold">Ridges at first light</strong><span class="mt-[3px] block text-[#5f5a52]">The handle sweeps once when it comes into view</span></figcaption>
</figure></div>

<div><span class="mb-4 block text-[.72rem] tracking-[.12em] text-[#5f5a52] uppercase">Container queries: wide and narrow container</span>
<div class="grid items-start gap-6 min-[768px]:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
  <figure class="@container relative">
    <div class="compare relative isolate block aspect-[3/2] overflow-hidden rounded-2xl bg-[#d8d3ca] select-none [--thumb:44px] [--v:50] @max-[380px]:aspect-[4/5]" 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" class="pointer-events-none absolute inset-0 block size-full object-cover" loading="lazy" alt="After: aerial view of dense green forest meeting a bright turquoise lake along a straight edge">
      <div class="clip-before absolute inset-0"><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" class="pointer-events-none absolute inset-0 block size-full object-cover brightness-88 contrast-80 saturate-35" loading="lazy" alt="Before: the same forest and lake, grey and flat"></div>
      <span class="pointer-events-none absolute top-[4cqi] rounded-full bg-[rgba(15,17,21,.78)] px-[11px] py-[5px] text-[clamp(.62rem,2.2cqi,.76rem)] font-bold tracking-[.08em] text-white uppercase left-[4cqi] opacity-[clamp(0,calc((var(--v)_-_14)/8),1)]" aria-hidden="true">Before <span class="@max-[380px]:hidden">· drone file</span></span><span class="pointer-events-none absolute top-[4cqi] rounded-full bg-[rgba(15,17,21,.78)] px-[11px] py-[5px] text-[clamp(.62rem,2.2cqi,.76rem)] font-bold tracking-[.08em] text-white uppercase right-[4cqi] opacity-[clamp(0,calc((86_-_var(--v))/8),1)]" aria-hidden="true">After <span class="@max-[380px]:hidden">· graded</span></span>
      <input class="compare-range peer absolute inset-0 z-2 m-0 size-full cursor-ew-resize opacity-0" 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="pointer-events-none absolute inset-y-0 left-(--x) z-1 w-[3px] -translate-x-1/2 bg-white shadow-[0_0_10px_rgba(0,0,0,.35)]" data-handle aria-hidden="true"></span>
      <span class="pointer-events-none absolute top-1/2 left-(--x) z-1 grid size-[46px] -translate-1/2 place-items-center rounded-full bg-white text-[#16181d] shadow-[0_4px_16px_rgba(0,0,0,.3)] peer-focus-visible:shadow-[0_0_0_3px_#fff] peer-focus-visible:outline-3 peer-focus-visible:outline-offset-3 peer-focus-visible:outline-[#1d4ed8] @max-[380px]:size-[34px]" aria-hidden="true"><svg class="size-[22px] @max-[380px]:size-4" 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>
    </div>
    <figcaption class="pointer-events-none absolute inset-x-0 bottom-0 z-3 rounded-b-2xl bg-linear-to-t from-[rgba(10,12,16,.85)] to-[rgba(10,12,16,0)] px-[18px] pt-10 pb-3.5 text-white @max-[380px]:static @max-[380px]:bg-none @max-[380px]:px-0.5 @max-[380px]:pt-3 @max-[380px]:pb-0 @max-[380px]:text-[#16181d]"><strong class="block text-[1.05rem]">Lake edge, aerial</strong><span class="mt-0.5 block text-[.84rem] text-[#e6e4df] @max-[380px]:text-[#5f5a52]">Caption over the photo when wide, below it when narrow</span></figcaption>
  </figure>
  <figure class="@container relative">
    <div class="compare relative isolate block aspect-[3/2] overflow-hidden rounded-2xl bg-[#d8d3ca] select-none [--thumb:44px] [--v:50] @max-[380px]:aspect-[4/5]" 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" class="pointer-events-none absolute inset-0 block size-full object-cover" loading="lazy" alt="After: aerial view of dense green forest meeting a bright turquoise lake along a straight edge">
      <div class="clip-before absolute inset-0"><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" class="pointer-events-none absolute inset-0 block size-full object-cover brightness-88 contrast-80 saturate-35" loading="lazy" alt="Before: the same forest and lake, grey and flat"></div>
      <span class="pointer-events-none absolute top-[4cqi] rounded-full bg-[rgba(15,17,21,.78)] px-[11px] py-[5px] text-[clamp(.62rem,2.2cqi,.76rem)] font-bold tracking-[.08em] text-white uppercase left-[4cqi] opacity-[clamp(0,calc((var(--v)_-_14)/8),1)]" aria-hidden="true">Before <span class="@max-[380px]:hidden">· drone file</span></span><span class="pointer-events-none absolute top-[4cqi] rounded-full bg-[rgba(15,17,21,.78)] px-[11px] py-[5px] text-[clamp(.62rem,2.2cqi,.76rem)] font-bold tracking-[.08em] text-white uppercase right-[4cqi] opacity-[clamp(0,calc((86_-_var(--v))/8),1)]" aria-hidden="true">After <span class="@max-[380px]:hidden">· graded</span></span>
      <input class="compare-range peer absolute inset-0 z-2 m-0 size-full cursor-ew-resize opacity-0" 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="pointer-events-none absolute inset-y-0 left-(--x) z-1 w-[3px] -translate-x-1/2 bg-white shadow-[0_0_10px_rgba(0,0,0,.35)]" data-handle aria-hidden="true"></span>
      <span class="pointer-events-none absolute top-1/2 left-(--x) z-1 grid size-[46px] -translate-1/2 place-items-center rounded-full bg-white text-[#16181d] shadow-[0_4px_16px_rgba(0,0,0,.3)] peer-focus-visible:shadow-[0_0_0_3px_#fff] peer-focus-visible:outline-3 peer-focus-visible:outline-offset-3 peer-focus-visible:outline-[#1d4ed8] @max-[380px]:size-[34px]" aria-hidden="true"><svg class="size-[22px] @max-[380px]:size-4" 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>
    </div>
    <figcaption class="pointer-events-none absolute inset-x-0 bottom-0 z-3 rounded-b-2xl bg-linear-to-t from-[rgba(10,12,16,.85)] to-[rgba(10,12,16,0)] px-[18px] pt-10 pb-3.5 text-white @max-[380px]:static @max-[380px]:bg-none @max-[380px]:px-0.5 @max-[380px]:pt-3 @max-[380px]:pb-0 @max-[380px]:text-[#16181d]"><strong class="block text-[1.05rem]">Lake edge, aerial</strong><span class="mt-0.5 block text-[.84rem] text-[#e6e4df] @max-[380px]:text-[#5f5a52]">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; }
@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; }
}
/* Scroll-driven reveal: only where view() timelines exist, and only without reduced motion */
@utility scroll-reveal {
  @supports (animation-timeline: view()) {
    @media (prefers-reduced-motion: no-preference) {
      animation: sr-reveal linear both; animation-timeline: view(); animation-range: entry 30% cover 65%;
    }
  }
}
@keyframes sr-reveal { from { --v: 100; } to { --v: 0; } }
@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; } }
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'); });
});

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.