Before/After Image Comparison Slider with Alpine.js, No Library
AI generated
x-data
Alpine
Alpine.js · Image Comparison · Case Study
Before/After Image Comparison with Alpine.js
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.

20 min read x-data · Pointer Events · clip-path Alpine.js 3.x

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.

11. FAQ: Image Comparison Slider with Alpine.js

1Why clip-path instead of width?
clip-path only changes the visible area and is GPU rendered, no layout reflow.
2How does the handle work?
Its left position is bound to the same variable as the clip-path, so it never drifts apart.
3How are mouse and touch unified?
Through the pointer events API with identical behavior across input devices.
4What is setPointerCapture for?
So move events stay bound to the origin element even when the cursor leaves the container.
5Is keyboard control possible?
Yes, via ARIA role slider plus arrow key, home and end handlers.
6Why does the slider sometimes stutter?
Without throttling, Alpine.js processes every pointermove event, requestAnimationFrame fixes that.
7Also possible vertically?
Yes, with inset(Y% 0 0 0) and clientY instead of clientX.
8Suitable for responsive layouts?
Yes, since the position is stored as a percentage independent of container width.
9How is 0 to 100 enforced?
With Math.min(100, Math.max(0, raw)) on every position calculation.
10Build it or use jQuery?
Almost always build it for new projects, due to smaller bundle size and GPU rendering.