An image comparison slider without an external library
An image comparison slider shows two overlaid images and lets users drag the boundary between before and after. With CSS clip-path, unified pointer events and Alpine.js as a reactive state layer, this interaction comes together with no jQuery plugin and no extra slider framework at all.
Table of Contents
- 1. Why an image comparison slider is a great Alpine.js example
- 2. Basic structure: two stacked images and clip-path
- 3. x-data state: slider position as a reactive variable
- 4. Unifying mouse and touch with pointer events
- 5. Clip-path calculation: translating a percentage into CSS
- 6. The handle: positioning the visual drag element
- 7. Keyboard control for accessibility
- 8. Performance: requestAnimationFrame for smooth updates
- 9. Image comparison slider approaches compared
- 10. Summary
- 11. FAQ
1. Why an image comparison slider is a great Alpine.js example
An image comparison slider is one of those components constantly needed on landing pages, in portfolios and for before and after presentations of renovations or photo edits. The typical solution is a ready made jQuery plugin or an entire slider library, even though the actual technique behind an image comparison slider is remarkably simple: two images stacked on top of each other, one of which is only partially visible through CSS clip-path, plus a handle that moves this clipped area as the user drags.
Alpine.js is particularly well suited to an image comparison slider because the entire logic boils down to a single reactive number: the slider's position as a percentage. This one number simultaneously drives the clip-path of the top image and the horizontal position of the handle, with no manual DOM update needed. Where a jQuery plugin quickly brings ten kilobytes and its own CSS files, the image comparison slider built with Alpine.js gets by with just a few lines.
This article walks through the whole path, from the basic structure through unified pointer events to keyboard control. By the end there is an image comparison slider that works with the mouse on desktop, via touch on mobile devices and through the keyboard, and that stays smooth even during fast movement.
2. Basic structure: two stacked images and clip-path
The basic structure of an image comparison slider consists of a relatively positioned container in which two images sit exactly on top of each other. The bottom image, usually the after image, fills the container completely. The top image, usually the before image, is positioned absolutely directly above it and gets clipped from the right via clip-path: inset(0 X% 0 0), where X is the current slider position as a percentage.
This technique has a decisive advantage over older approaches that rely on width and overflow: hidden: clip-path does not change the element's box model, only its visible area. The image therefore stays in the DOM at its full original size, only a varying portion of it is displayed. This prevents distortion and makes the image comparison slider independent of the actual image width inside the container.
<div
x-data="beforeAfterSlider()"
class="relative w-full aspect-video overflow-hidden rounded-xl select-none"
>
<!-- After image: fills the container completely -->
<img src="/media/after.jpg" alt="After" class="absolute inset-0 w-full h-full object-cover">
<!-- Before image: clipped from the right based on slider position -->
<img
src="/media/before.jpg"
alt="Before"
class="absolute inset-0 w-full h-full object-cover"
:style="`clip-path: inset(0 ${100 - position}% 0 0)`"
>
</div>
3. x-data state: slider position as a reactive variable
The entire state of an image comparison slider reduces to a single number between 0 and 100, the position as a percentage from the left. Every derived value, the clip-path of the top image and the left position of the handle, is computed directly from this one variable, never stored separately. This prevents the handle and the image boundary from ever drifting apart, a common problem in jQuery implementations that update both values separately.
In addition to the position, the image comparison slider needs a flag for whether it is currently being dragged, and a reference to the container to calculate the relative position inside the element during mouse movements. Alpine.js provides exactly this DOM element access through $refs and x-ref, with no need for an additional document.querySelector call.
function beforeAfterSlider() {
return {
position: 50, // percentage from left, single source of truth
isDragging: false,
startDrag() {
this.isDragging = true;
},
stopDrag() {
this.isDragging = false;
},
// Convert a clientX coordinate into a 0-100 percentage relative to the container
updateFromClientX(clientX) {
const rect = this.$refs.container.getBoundingClientRect();
const raw = ((clientX - rect.left) / rect.width) * 100;
this.position = Math.min(100, Math.max(0, raw));
},
};
}
4. Unifying mouse and touch with pointer events
A common mistake with a self built image comparison slider is writing separate handlers for mousedown/mousemove and touchstart/touchmove. This duplicates the code and regularly leads to inconsistencies between desktop and mobile behavior. The pointer events API solves this problem by unifying mouse, touch and pen input under a single event type: pointerdown, pointermove and pointerup behave identically across all input devices.
Crucial for a smooth image comparison slider is setPointerCapture(). Without this call, the element loses its connection to the pointer as soon as the cursor leaves the container while dragging, for example during a fast mouse movement. With event.target.setPointerCapture(event.pointerId), all subsequent pointermove events remain bound to the original element, no matter where the cursor currently sits.
function beforeAfterSlider() {
return {
position: 50,
isDragging: false,
onPointerDown(event) {
this.isDragging = true;
// Keep receiving move events even if the cursor leaves the element
event.target.setPointerCapture(event.pointerId);
this.updateFromClientX(event.clientX);
},
onPointerMove(event) {
if (!this.isDragging) return;
this.updateFromClientX(event.clientX);
},
onPointerUp(event) {
this.isDragging = false;
event.target.releasePointerCapture(event.pointerId);
},
updateFromClientX(clientX) {
const rect = this.$refs.container.getBoundingClientRect();
const raw = ((clientX - rect.left) / rect.width) * 100;
this.position = Math.min(100, Math.max(0, raw));
},
};
}
5. Clip-path calculation: translating a percentage into CSS
The clip-path syntax for an image comparison slider uses the inset() function with four values in the order top, right, bottom, left. Since the top image area should be clipped from the right, only the second value matters: inset(0 X% 0 0), where X is the difference between 100 and the current slider position. At a slider position of 30 percent, the image is clipped by 70 percent from the right, so the leftmost 30 percent of the before image remains visible.
As an alternative to clip-path, the same effect can be achieved with clip: rect(), but that is considered deprecated and offers no advantage in modern browsers. An image comparison slider built with clip-path also benefits from modern browsers rendering this property on the GPU, which is noticeably more performant during frequent updates while dragging than a width change through width, which triggers a full layout reflow.
/* Static fallback styling — the dynamic clip-path value comes from Alpine :style */
.comparison-slider-before {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
will-change: clip-path; /* hint the browser this property animates */
}
/* Example computed value at position = 35 */
.comparison-slider-before-example {
clip-path: inset(0 65% 0 0);
}
6. The handle: positioning the visual drag element
The handle of an image comparison slider is a purely visual element that makes the current position visible and shows the user where dragging is possible. Technically the handle is an absolutely positioned divider line with a round drag knob in the middle, whose left property is bound directly to the very same position variable that also drives the clip-path. Because both values come from the same source, the handle and image boundary drifting apart is technically ruled out.
For a good user experience, the entire container of the image comparison slider should act as the drag surface, not just the narrow handle itself. A click anywhere on the image should immediately jump the slider to that position before dragging continues. The onPointerDown handler already fulfills this expectation, since it calls updateFromClientX directly on the very first click, not only on the first movement.
<div
x-data="beforeAfterSlider()"
x-ref="container"
@pointerdown="onPointerDown($event)"
@pointermove="onPointerMove($event)"
@pointerup="onPointerUp($event)"
class="relative w-full aspect-video overflow-hidden rounded-xl select-none cursor-ew-resize"
>
<img src="/media/after.jpg" alt="After" class="absolute inset-0 w-full h-full object-cover">
<img src="/media/before.jpg" alt="Before" class="absolute inset-0 w-full h-full object-cover"
:style="`clip-path: inset(0 ${100 - position}% 0 0)`">
<!-- Divider line + drag handle, bound to the same position value -->
<div class="absolute inset-y-0 w-0.5 bg-white shadow-lg" :style="`left: ${position}%`">
<div class="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-10 h-10 bg-white rounded-full shadow-xl flex items-center justify-center">
<span class="text-slate-700 text-xs font-bold">↔</span>
</div>
</div>
</div>
7. Keyboard control for accessibility
An image comparison slider operable only via mouse or touch excludes keyboard users, even though implementing a keyboard alternative only takes a few extra lines of code. The handle gets tabindex="0", the ARIA role slider and aria-valuemin, aria-valuemax and aria-valuenow, so screen readers correctly announce the current position. Via @keydown.left and @keydown.right, the position can then be changed in small increments.
For an accessible image comparison slider, @keydown.home and @keydown.end should additionally set the position to 0 and 100 percent respectively, matching the behavior of native range inputs. This small addition marks the difference between a purely decorative gimmick and a component genuinely usable by every user group.
function beforeAfterSlider() {
return {
position: 50,
step: 2,
onKeydown(event) {
const actions = {
ArrowLeft: () => this.position = Math.max(0, this.position - this.step),
ArrowRight: () => this.position = Math.min(100, this.position + this.step),
Home: () => this.position = 0,
End: () => this.position = 100,
};
if (actions[event.key]) {
event.preventDefault();
actions[event.key]();
}
},
};
}
8. Performance: requestAnimationFrame for smooth updates
During fast mouse movement, an image comparison slider can start to stutter if every single pointermove event immediately triggers an Alpine.js reactivity update. Browsers fire pointermove far more often than the layout can actually be repainted, typically several hundred times per second with high resolution input devices. Without throttling, Alpine.js processes every single event, leading to unnecessary work.
The solution is to couple the actual state update to requestAnimationFrame. The last known mouse position value is cached in a plain variable, and only the next animation frame applies this value to the reactive position property. This way the image comparison slider updates at most as often as the screen actually repaints, typically 60 times per second, regardless of how many pointermove events the browser fires in between.
function beforeAfterSlider() {
return {
position: 50,
isDragging: false,
pendingClientX: null,
rafScheduled: false,
onPointerMove(event) {
if (!this.isDragging) return;
this.pendingClientX = event.clientX;
this.scheduleUpdate();
},
// Coalesce many pointermove events into one update per animation frame
scheduleUpdate() {
if (this.rafScheduled) return;
this.rafScheduled = true;
requestAnimationFrame(() => {
const rect = this.$refs.container.getBoundingClientRect();
const raw = ((this.pendingClientX - rect.left) / rect.width) * 100;
this.position = Math.min(100, Math.max(0, raw));
this.rafScheduled = false;
});
},
};
}
9. Image comparison slider approaches compared
Several technical implementations exist for an image comparison slider, each with different trade offs regarding performance, bundle size and browser support.
| Approach | Bundle size | Rendering | Best fit |
|---|---|---|---|
| clip-path plus Alpine.js | 0 KB extra | GPU accelerated | Default case, any project size |
| width plus overflow:hidden | 0 KB extra | Layout reflow per update | Only for very simple cases |
| jQuery comparison plugin | about 15 to 25 KB gzip | Depends on the plugin | Only in existing jQuery projects |
| SVG clip path overlay | 0 KB extra | GPU accelerated | Complex shapes beyond rectangles |
In practice, clip-path combined with Alpine.js is convincing for an image comparison slider due to the combination of minimal code, no additional bundle size and GPU accelerated rendering. Only for very exotic clipping shapes beyond a simple vertical divider does the extra effort of an SVG based solution pay off.
Mironsoft
Alpine.js components for Hyvä, Magento and custom frontends
Need a custom image comparison slider or another Alpine.js component?
We build custom Alpine.js components, from interactive image comparisons to galleries and complex forms, cleanly integrated into your existing Hyvä or Magento frontend.
Concept
Clarifying interaction patterns and performance requirements
Implementation
Pointer events, clip-path and keyboard operation from a single source
Integration
Clean integration into existing Hyvä and Magento frontends
10. Summary
A performant image comparison slider needs no external library, only three ingredients: CSS clip-path to clip the top image, the pointer events API for unified handling of mouse, touch and pen, and Alpine.js as a lightweight reactive layer that translates a single position variable into several derived visual outputs. This combination saves several kilobytes compared to jQuery plugins while also delivering GPU accelerated rendering.
For production use, two further building blocks are worth adding: keyboard control with an ARIA slider role for accessibility, and throttling of state updates via requestAnimationFrame, so the image comparison slider stays smooth even during fast mouse movement. Both additions together require fewer than twenty extra lines of code.
Image Comparison Slider with Alpine.js — The Essentials at a Glance
Technique
CSS clip-path: inset() clips the top image based on a single percentage variable.
Input
Pointer events (pointerdown/pointermove/pointerup) unify mouse, touch and pen in one handler set.
Accessibility
ARIA role slider, keyboard control with arrow keys, home and end.
Performance
requestAnimationFrame couples state updates to the actual screen refresh rate.