Before/After Split Slider

A draggable before/after image comparison slider powered by GSAP Draggable. The "after" layer uses an animated clip-path mask so any content — images, video, or any element — can sit inside either panel. Initial split position is controlled via a data attribute.

gsapdraggablebefore/aftercomparisonclip-pathslider

Setup — External Scripts

CDN — GSAP (add before </body>)
html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.7/dist/gsap.min.js"></script>
CDN — Draggable (add after GSAP)
html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.7/dist/Draggable.min.js"></script>

Code

HTML
html
<div data-splitter-initial="25" data-splitter="wrap" class="splitter-wrapper">
  <div class="splitter-content"><img src="images/osmo-splitter-before.avif" loading="lazy" alt="" class="splitter-content__img"></div>
  <div data-splitter="after" class="splitter-content is--after"><img src="images/osmo-splitter-after.avif" loading="lazy" alt="" class="splitter-content__img"></div>
  <div data-splitter="handle" class="splitter-handle">
    <div class="splitter-handle__center">
      <svg xmlns="http://www.w3.org/2000/svg" width="100%" viewBox="0 0 24 24" fill="none" class="splitter-handle__icon">
        <path d="M20.7931 11.5L15.2931 5.99995L16.0002 5.29285L22.3537 11.6464V12.3535L16.0002 18.7071L15.2931 18L20.793 12.5L3.20719 12.5L8.70714 18L8.00004 18.7071L1.64648 12.3535L1.64648 11.6464L8.00004 5.29285L8.70714 5.99995L3.2071 11.5L20.7931 11.5Z" fill="currentColor"></path>
      </svg>
    </div>
  </div>
</div>
CSS
css
.splitter-wrapper {
  aspect-ratio: 3 / 2;
  border-radius: 2rem;
  width: min(95vw, 60em);
  position: relative;
  overflow: hidden;
}

.splitter-content {
  z-index: 0;
  width: 100%;
  height: 100%;
  position: absolute;
  inset: 0%;
}

.splitter-content.is--after {
  -webkit-clip-path: inset(0 0 0 25%);
  clip-path: inset(0 0 0 25%);
}

.splitter-content__img {
  object-fit: cover;
  width: 100%;
  height: 100%;
}

.splitter-handle {
  z-index: 2;
  cursor: ew-resize;
  background-color: #fff;
  justify-content: center;
  align-items: center;
  width: .25em;
  height: 100%;
  display: flex;
  position: absolute;
  top: 0;
  bottom: 0;
  left: 25%;
}

.splitter-handle__center {
  grid-column-gap: .125em;
  grid-row-gap: .125em;
  background-color: #fff;
  border-radius: 100em;
  flex: none;
  justify-content: center;
  align-items: center;
  width: 2.5em;
  height: 2.5em;
  display: flex;
  position: relative;
}

.splitter-handle__icon {
  justify-content: center;
  align-items: center;
  width: 1.25em;
  display: flex;
}

img::selection {
  background: none;
}

.splitter-handle__center::after {
  content: '';
  position: absolute;
  z-index: 1;
  width: 100%;
  height: 100%;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  border-radius: 100em;
  opacity: 1;
  border: 1px solid white;
  transition: all 0.4s cubic-bezier(0.35, 1, 0.6, 1);
}

.splitter-handle:hover .splitter-handle__center::after {
  width: 130%;
  height: 130%;
  opacity: 0.5;
}
JavaScript
javascript
gsap.registerPlugin(Draggable);

function initBeforeAfterSplitSlider() {
  const splitters = document.querySelectorAll('[data-splitter="wrap"]');

  const setupSplitter = (splitter) => {
    const handle = splitter.querySelector('[data-splitter="handle"]');
    const after  = splitter.querySelector('[data-splitter="after"]');

    let bounds         = splitter.getBoundingClientRect();
    let currentPercent = parseFloat(splitter.getAttribute('data-splitter-initial')) || 50;

    const setPositions = (percent) => {
      bounds = splitter.getBoundingClientRect();
      const positionX = (percent / 100) * bounds.width;
      gsap.set(handle, { x: positionX, left: "unset" });
      gsap.set(after,  { clipPath: `inset(0 0 0 ${percent}%)` });
    };

    setPositions(currentPercent);

    Draggable.create(handle, {
      type: 'x',
      bounds: splitter,
      cursor: 'ew-resize',
      activeCursor: 'grabbing',
      onDrag() {
        currentPercent = (this.x / bounds.width) * 100;
        gsap.set(after, { clipPath: `inset(0 0 0 ${currentPercent}%)` });
      }
    });

    window.addEventListener('resize', () => setPositions(currentPercent));
  };

  splitters.forEach(setupSplitter);
}

// Initialize Before After Split Slider
document.addEventListener("DOMContentLoaded", () => {
  initBeforeAfterSplitSlider();
});

Attributes

NameTypeDefaultDescription
[data-splitter="wrap"]attributeThe root wrapper element. Contains both content panels and the handle. Draggable uses this as the drag bounds.
[data-splitter-initial]number (0–100)50Sets the initial position of the handle as a percentage of the wrapper width. 0 shows only the "after" panel; 100 shows only the "before" panel. No CSS changes needed per instance.
[data-splitter="after"]attributeThe "after" content panel. Its clip-path is animated on drag to reveal or hide the content. Can contain any element — image, video, or arbitrary HTML.
[data-splitter="handle"]attributeThe draggable handle element. GSAP Draggable attaches to this element and constrains it within the wrapper bounds.

Notes

  • Requires GSAP and Draggable loaded via CDN before the script runs.
  • The clip-path CSS on .splitter-content.is--after must match the data-splitter-initial value in the HTML to prevent a flash of incorrect position before JS runs.
  • bounds: splitter constrains the Draggable to the wrapper width — the handle can never be dragged outside the component.
  • window resize recalculates the bounds rect and repositions the handle and clip-path to the same percentage, so the split stays correct at all viewport sizes.
  • Multiple [data-splitter="wrap"] instances on the same page are fully supported — each gets its own Draggable instance and state.
  • img::selection { background: none } prevents the browser from highlighting images during drag, which would cause a visual glitch.

Guide

How it works

On drag, the script animates the clip-path value of the [data-splitter="after"] element. This essentially resizes a mask on the div, giving complete freedom over what goes inside — an image, video, or any other element.

Setting the initial position

Add data-splitter-initial with a number between 0–100 to the wrapper element to control the initial handle position on page load. This removes the need to edit CSS per instance.

<!-- Start at 50% (centered) -->
<div data-splitter="wrap" data-splitter-initial="50">

<!-- Start at 30% (mostly showing "before") -->
<div data-splitter="wrap" data-splitter-initial="30">

Using non-image content

Because the reveal is done via clip-path, the "after" panel can contain any element. Swap the img for a video, a canvas, or a fully styled div — it all works the same way.