Interactive Image Comparison Slider

A sleek before/after image comparison slider with smooth drag interactions, touch device support, custom glassmorphic handle design, and an automatic intro animation.


Interactive Image Comparison Slider


Give users a powerful, interactive way to view “before and after” states with this premium image comparison slider. Designed to feel buttery smooth on both desktop and mobile, it features a custom glassmorphic drag handle, intelligent touch support, and an auto-slide intro animation to guide users on how to use it.

Design Highlights

Code Snippet

HTML Structure

The markup stacks two images on top of each other and uses a standard range input invisibly laid over the custom handle to provide robust accessibility and native drag physics.

<div class="comparison-slider" id="imageCompare">
  <!-- Base 'After' Image -->
  <img src="/path/to/after.jpg" alt="After" class="slider-img after-img" />
  
  <!-- Overlay 'Before' Image (Clipped) -->
  <img src="/path/to/before.jpg" alt="Before" class="slider-img before-img" id="beforeImage" />
  
  <!-- Custom Slider Handle UI -->
  <div class="slider-handle" id="sliderHandle">
    <div class="handle-line"></div>
    <div class="handle-button">
      <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
        <polyline points="15 18 9 12 15 6"></polyline>
      </svg>
      <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
        <polyline points="9 18 15 12 9 6"></polyline>
      </svg>
    </div>
  </div>

  <!-- Native Range Input (Hidden but accessible) -->
  <input type="range" min="0" max="100" value="50" class="slider-input" id="sliderInput" aria-label="Percentage of before photo shown" />
</div>

CSS Styles

The magic happens by dynamically updating a CSS variable (--position) that controls both the clip-path of the top image and the horizontal position of the handle.

.comparison-slider {
  --position: 50%;
  position: relative;
  width: 100%;
  max-width: 800px;
  border-radius: 1rem;
  overflow: hidden;
  box-shadow: 0 10px 30px rgba(0,0,0,0.3);
}

.slider-img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  pointer-events: none;
}

.after-img {
  position: relative;
}

/* The magic clipping */
.before-img {
  position: absolute;
  top: 0;
  left: 0;
  /* Clips the right side of the top image dynamically */
  clip-path: polygon(0 0, var(--position) 0, var(--position) 100%, 0 100%);
}

/* Custom Visual Handle */
.slider-handle {
  position: absolute;
  top: 0;
  bottom: 0;
  left: var(--position);
  width: 2px;
  background: white;
  transform: translateX(-50%);
  pointer-events: none;
  z-index: 10;
}

.handle-button {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 48px;
  height: 48px;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.2);
  backdrop-filter: blur(8px);
  border: 2px solid white;
  display: flex;
  align-items: center;
  justify-content: center;
}

/* 
 * Invisible Range Input over the whole container
 * This captures all touch & drag events natively for free!
 */
.slider-input {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  opacity: 0;
  cursor: ew-resize;
  z-index: 20;
  margin: 0;
}

JavaScript Logic

We map the invisible <input type="range"> value to the CSS --position variable. Simple, accessible, and performant.

document.addEventListener('DOMContentLoaded', () => {
  const container = document.getElementById('imageCompare');
  const slider = document.getElementById('sliderInput');
  
  // Update CSS position based on slider
  const updateSlider = (val) => {
    container.style.setProperty('--position', `${val}%`);
  };

  // Listen to input events
  slider.addEventListener('input', (e) => {
    updateSlider(e.target.value);
  });

  // Optional: Auto-slide intro animation
  let animationFrames = 0;
  const startAnim = () => {
    if(animationFrames > 50) return; // Stop after a bit
    animationFrames++;
    // Math.sin creates a smooth back-and-forth oscillation
    const pos = 50 + Math.sin(animationFrames * 0.1) * 10;
    updateSlider(pos);
    slider.value = pos;
    requestAnimationFrame(startAnim);
  };
  
  // startAnim(); // Uncomment to run
});

By utilizing a hidden native <input type="range">, we completely bypass the need for complex, buggy mousedown and mousemove absolute coordinate tracking, providing the absolute best experience out-of-the-box.