Blog / CSS/JS

Before/After Comparison Slider

Learn to create an interactive comparison slider that allows users to reveal two states of an image or element. Ideal for portfolios, photo editing and product demonstrations.

Introduction to comparison sliders

Before/after comparison sliders are extremely popular UI components for presenting visual transformations. Whether to show a photo retouch, a before/after renovation, or the difference between two design versions, this pattern offers an intuitive and engaging interaction.

In this complete tutorial, we'll explore several approaches to creating this type of slider: from the full JavaScript version with touch support, to a 100% CSS version using an input range. Each method has its advantages depending on your needs.

💡
What you will learn

The CSS clip-path technique for revealing an image, JavaScript drag with mouse and touch event handling, and an elegant CSS-only approach with input[type="range"].

Basic HTML structure

The structure of a comparison slider relies on two superimposed layers: the "after" image in the background and the "before" image on top, partially visible thanks to a CSS mask. A handle lets the user control the reveal area.

comparison-slider.html
<div class="comparison-slider">
  <!-- Back layer: AFTER image -->
  <div class="comparison-layer after">
    <img src="after.jpg" alt="After"/>
  </div>

  <!-- Front layer: BEFORE image (masked) -->
  <div class="comparison-layer before">
    <img src="before.jpg" alt="Before"/>
  </div>

  <!-- Draggable handle -->
  <div class="comparison-handle"></div>

  <!-- Optional labels -->
  <span class="comparison-label before">Before</span>
  <span class="comparison-label after">After</span>
</div>

Key structural points

  • Layer order: The "after" image comes first in the DOM but appears behind thanks to z-index
  • Position relative: The parent container must be set to position: relative so that absolute children are positioned correctly
  • Aspect ratio: Set a fixed height or use aspect-ratio to maintain proportions
  • Overflow hidden: Essential to hide the overflowing parts of images

CSS: the magic of clip-path

The CSS clip-path property is the key to this component. It allows you to define a visible area for an element, hiding everything outside it. For our slider, we use inset() which works like an inner frame.

AFTER
BEFORE
Before After
comparison-slider.css
.comparison-slider {
  position: relative;
  width: 100%;
  max-width: 600px;
  aspect-ratio: 16 / 10;
  overflow: hidden;
  border-radius: 12px;
  cursor: ew-resize;
  user-select: none;
}

.comparison-layer {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
}

.comparison-layer img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.comparison-layer.after {
  z-index: 1;
}

.comparison-layer.before {
  z-index: 2;
  /* clip-path: inset(top right bottom left) */
  /* Shows 50% left of the BEFORE image */
  clip-path: inset(0 50% 0 0);
}

Understanding clip-path: inset()

The inset() function takes 4 values in order: top, right, bottom, left (like margins). These values define the distance from each edge inward:

  • inset(0 50% 0 0): masks 50% from the right, showing the left half
  • inset(0 0 0 0): no mask, image fully visible
  • inset(0 100% 0 0): masks everything from the right, image invisible
Performance

clip-path is very performant as it is GPU-accelerated. This is much better than modifying the width of an element, which triggers a layout reflow.

Horizontal slider with draggable handle

Now, let's add JavaScript interactivity. The handle must follow the user's cursor and update the clip-path in real time. We need to handle the mousedown, mousemove and mouseup events.

comparison-handle.css
.comparison-handle {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 50%;
  width: 4px;
  background: white;
  transform: translateX(-50%);
  z-index: 10;
  box-shadow: 0 0 10px rgba(0,0,0,0.3);
}

/* Handle circle */
.comparison-handle::before {
  content: '';
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 44px;
  height: 44px;
  background: white;
  border-radius: 50%;
  box-shadow: 0 2px 12px rgba(0,0,0,0.4);
}

/* Icone fleches */
.comparison-handle::after {
  content: '↔';
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  font-size: 1.2rem;
  color: #333;
}
comparison-slider.js
function initComparisonSlider(container) {
  const before = container.querySelector('.before');
  const handle = container.querySelector('.comparison-handle');
  let isDragging = false;

  // Calculate position as percentage
  function getPercent(clientX) {
    const rect = container.getBoundingClientRect();
    const x = clientX - rect.left;
    return Math.max(0, Math.min(100, (x / rect.width) * 100));
  }

  // Update the slider
  function updateSlider(percent) {
    before.style.clipPath = `inset(0 ${100 - percent}% 0 0)`;
    handle.style.left = percent + '%';
  }

  // Mouse events
  container.addEventListener('mousedown', (e) => {
    isDragging = true;
    updateSlider(getPercent(e.clientX));
  });

  document.addEventListener('mousemove', (e) => {
    if (!isDragging) return;
    updateSlider(getPercent(e.clientX));
  });

  document.addEventListener('mouseup', () => {
    isDragging = false;
  });
}

// Initialisation
initComparisonSlider(document.querySelector('.comparison-slider'));

JavaScript code explanation

  • getPercent(): Calculates the cursor position as a percentage of the container width
  • Math.max/min: Limits the value between 0 and 100 to prevent overflow
  • isDragging: Flag to know whether the user is currently dragging
  • Listeners on document: mousemove and mouseup events are on the document to continue dragging even if the cursor leaves the slider

Vertical slider

An interesting variant is the vertical slider, where the user slides from top to bottom. You just need to adapt the clip-path and the handle position. This version is perfect for portrait comparisons or timelines.

AFTER
BEFORE
vertical-slider.css
.comparison-slider.vertical {
  cursor: ns-resize;
}

.comparison-slider.vertical .before {
  /* Mask from bottom instead of right */
  clip-path: inset(0 0 50% 0);
}

.comparison-slider.vertical .comparison-handle {
  /* Horizontal bar */
  top: 50%;
  bottom: auto;
  left: 0;
  right: 0;
  width: auto;
  height: 4px;
  transform: translateY(-50%);
}

.comparison-slider.vertical .comparison-handle::after {
  transform: translate(-50%, -50%) rotate(90deg);
}
vertical-slider.js
function initVerticalSlider(container) {
  const before = container.querySelector('.before');
  const handle = container.querySelector('.comparison-handle');
  let isDragging = false;

  function getPercent(clientY) {
    const rect = container.getBoundingClientRect();
    const y = clientY - rect.top;
    return Math.max(0, Math.min(100, (y / rect.height) * 100));
  }

  function updateSlider(percent) {
    // inset: top right bottom left
    before.style.clipPath = `inset(0 0 ${100 - percent}% 0)`;
    handle.style.top = percent + '%';
  }

  container.addEventListener('mousedown', (e) => {
    isDragging = true;
    updateSlider(getPercent(e.clientY));
  });

  document.addEventListener('mousemove', (e) => {
    if (!isDragging) return;
    updateSlider(getPercent(e.clientY));
  });

  document.addEventListener('mouseup', () => isDragging = false);
}

CSS-only version with input range

An elegant approach without JavaScript consists of using an invisible <input type="range"> overlaid on the slider. The user interacts with the native range, and we use CSS variables to update the display.

AFTER
BEFORE
css-only-slider.html
<div class="comparison-css-only" style="--pos: 50">
  <div class="layer after"><img src="after.jpg"/></div>
  <div class="layer before"><img src="before.jpg"/></div>
  <div class="handle"></div>

  <!-- Input range invisible -->
  <input
    type="range"
    min="0"
    max="100"
    value="50"
    oninput="this.parentElement.style.setProperty('--pos', this.value)"
  />
</div>
css-only-slider.css
.comparison-css-only {
  position: relative;
  overflow: hidden;
  --pos: 50; /* Variable CSS */
}

.comparison-css-only .before {
  clip-path: inset(0 calc((100 - var(--pos)) * 1%) 0 0);
}

.comparison-css-only .handle {
  left: calc(var(--pos) * 1%);
}

/* Input range invisible */
.comparison-css-only input[type="range"] {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  margin: 0;
  opacity: 0;
  cursor: ew-resize;
  z-index: 20;
  -webkit-appearance: none;
}
👍
CSS-only advantage

This approach works without JavaScript and benefits from native browser touch support. The input range automatically handles drag on mobile without additional code.

Touch support and accessibility

For a complete mobile experience, we need to add touch events. Additionally, accessibility is important: keyboard users must be able to control the slider.

touch-support.js
function initComparisonSliderFull(container) {
  const before = container.querySelector('.before');
  const handle = container.querySelector('.comparison-handle');
  let isDragging = false;
  let currentPercent = 50;

  function getPercent(clientX) {
    const rect = container.getBoundingClientRect();
    return Math.max(0, Math.min(100,
      ((clientX - rect.left) / rect.width) * 100
    ));
  }

  function updateSlider(percent) {
    currentPercent = percent;
    before.style.clipPath = `inset(0 ${100 - percent}% 0 0)`;
    handle.style.left = percent + '%';
  }

  // Souris
  container.addEventListener('mousedown', e => {
    isDragging = true;
    updateSlider(getPercent(e.clientX));
  });

  document.addEventListener('mousemove', e => {
    if (isDragging) updateSlider(getPercent(e.clientX));
  });

  document.addEventListener('mouseup', () => isDragging = false);

  // Touch
  container.addEventListener('touchstart', e => {
    isDragging = true;
    updateSlider(getPercent(e.touches[0].clientX));
  }, { passive: true });

  container.addEventListener('touchmove', e => {
    if (isDragging) {
      updateSlider(getPercent(e.touches[0].clientX));
    }
  }, { passive: true });

  container.addEventListener('touchend', () => isDragging = false);

  // Clavier (accessibilite)
  container.setAttribute('tabindex', '0');
  container.setAttribute('role', 'slider');
  container.setAttribute('aria-valuemin', '0');
  container.setAttribute('aria-valuemax', '100');
  container.setAttribute('aria-valuenow', '50');

  container.addEventListener('keydown', e => {
    const step = e.shiftKey ? 10 : 2;
    if (e.key === 'ArrowLeft') {
      updateSlider(currentPercent - step);
      container.setAttribute('aria-valuenow', currentPercent);
    } else if (e.key === 'ArrowRight') {
      updateSlider(currentPercent + step);
      container.setAttribute('aria-valuenow', currentPercent);
    }
  });
}

Important points for accessibility

  • tabindex="0": Allows the slider to receive keyboard focus
  • role="slider": Tells screen readers the type of component
  • aria-value*: Communicates the current value and limits
  • Arrow keys: Allow moving the slider without a mouse
  • Shift + arrow: Faster movement (10% instead of 2%)
⚠️
passive: true for touch events

The { passive: true } option improves performance on mobile by telling the browser that the event will not be cancelled with preventDefault(). This allows smoother scrolling.

Best practices and tips

Here are some recommendations for creating professional and performant comparison sliders.

Performance

  • Optimize your images: Use modern formats (WebP, AVIF) and appropriate sizes
  • Lazy loading: Load images only when the slider is visible
  • Prefer clip-path: More performant than modifying width or left on images
  • Avoid complex box-shadows: They can slow down rendering during drag

UX Design

  • Initial position: 50% is the standard, but adapt it to your content
  • Visible handle: It must clearly indicate that interaction is possible
  • Labels: Add "Before"/"After" to clarify
  • Cursor: Use ew-resize or col-resize

Ideal use cases

  • Photo retouching and filters
  • Renovation and architecture (before/after works)
  • Design and mockup comparison
  • Product demonstration (with/without feature)
  • Temporal evolution (historical vs current)
reduced-motion.css
/* Respect user preferences */
@media (prefers-reduced-motion: reduce) {
  .comparison-slider * {
    transition: none !important;
  }
}

/* Visible focus for accessibility */
.comparison-slider:focus-visible {
  outline: 3px solid #6366f1;
  outline-offset: 2px;
}

Conclusion

The before/after comparison slider is a versatile component that considerably enriches the user experience. Whether you choose the full JavaScript approach for total control, or the CSS-only version for its simplicity, you now have all the necessary techniques.

Key takeaways:

  • clip-path: inset() is your best friend for this type of effect
  • Always think about touch support and keyboard accessibility
  • The CSS-only version with input range is perfect for simple cases
  • Optimize your images for smooth performance
🎨
Go further

Find ready-to-use comparison sliders in our effects library, with animated variants and customizable styles.