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.
Setup — External Scripts
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.7/dist/gsap.min.js"></script><script src="https://cdn.jsdelivr.net/npm/gsap@3.12.7/dist/Draggable.min.js"></script>Code
<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>.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;
}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
| Name | Type | Default | Description |
|---|---|---|---|
| [data-splitter="wrap"] | attribute | — | The root wrapper element. Contains both content panels and the handle. Draggable uses this as the drag bounds. |
| [data-splitter-initial] | number (0–100) | 50 | Sets 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"] | attribute | — | The "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"] | attribute | — | The 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.