Categories: CSSScriptWeb Design

JS Before/After Image Comparison Slider with Touch Support

This is a fully responsive image comparison component that allows you to drag a slider handle to reveal before/after versions of the same image side by side.

It uses CSS clip-path for the reveal effect, and works across desktop mouse events, mobile touch gestures, and keyboard navigation.

The comparison slider starts centered at 50% and clamps position values between 0 and 100, so the handle never escapes the container bounds.

Features:

  • Drag-to-reveal interaction using CSS clip-path on the before image layer.
  • Full touch event support for mobile and tablet devices.
  • Keyboard control via arrow keys, Home, and End keys on the slider knob.
  • ARIA attributes on the knob for screen reader compatibility.
  • Labels for the before and after states that animate in on hover.
  • A gradient-faded slider line that visually anchors the divider.
  • Smooth transition animations when not actively dragging, with instant updates during drag.
  • Responsive layout with an enforced 16:9 aspect ratio on the container.
  • A scale-down press animation on the knob for tactile feedback.

Use Cases:

  • Photo editing portfolios that show an original photo next to a retouched version.
  • E-commerce product pages that compare a dirty item with a cleaned or restored one.
  • Real estate listings that show a room before and after renovation.
  • Medical or scientific imaging apps that need to display two scans or datasets side by side.

How To Use It:

1. Build the HTML structure. The container holds two absolutely positioned image wrappers, two labels, a divider line, and the draggable knob. The before image sits above the after image in the stacking order. The clip-path on the before image is what controls how much of it is visible.

<div class="comparison-container" id="myComparison">
  <!-- Before image: clipped to reveal only the left portion -->
  <div class="image-wrapper image-before">
    <img src="photo-before.jpg" alt="Before retouching" />
  </div>
  <!-- After image: always fully visible underneath -->
  <div class="image-wrapper image-after">
    <img src="photo-after.jpg" alt="After retouching" />
  </div>
  <!-- Hover labels for each side -->
  <span class="label label-before">Before</span>
  <span class="label label-after">After</span>
  <!-- The vertical divider line -->
  <div class="slider-line" id="sliderLine"></div>
  <!-- The draggable circular knob -->
  <div class="slider-knob" id="sliderKnob">
    <!-- Six-dot grip SVG icon -->
    <svg xmlns="http://www.w3.org/2000/svg" width="800px" height="800px" viewBox="0 0 25 25" fill="none">
      <path fill-rule="evenodd" clip-rule="evenodd"
        d="M9.5 8C10.3284 8 11 7.32843 11 6.5C11 5.67157 10.3284 5 9.5 5C8.67157 5 8 5.67157 8 6.5C8 7.32843 8.67157 8 9.5 8ZM9.5 14C10.3284 14 11 13.3284 11 12.5C11 11.6716 10.3284 11 9.5 11C8.67157 11 8 11.6716 8 12.5C8 13.3284 8.67157 14 9.5 14ZM11 18.5C11 19.3284 10.3284 20 9.5 20C8.67157 20 8 19.3284 8 18.5C8 17.6716 8.67157 17 9.5 17C10.3284 17 11 17.6716 11 18.5ZM15.5 8C16.3284 8 17 7.32843 17 6.5C17 5.67157 16.3284 5 15.5 5C14.6716 5 14 5.67157 14 6.5C14 7.32843 14.6716 8 15.5 8ZM17 12.5C17 13.3284 16.3284 14 15.5 14C14.6716 14 14 13.3284 14 12.5C14 11.6716 14.6716 11 15.5 11C16.3284 11 17 11.6716 17 12.5ZM15.5 20C16.3284 20 17 19.3284 17 18.5C17 17.6716 16.3284 17 15.5 17C14.6716 17 14 17.6716 14 18.5C14 19.3284 14.6716 20 15.5 20Z"
        fill="#121923" />
    </svg>
  </div>
</div>

2. Add the CSS. These CSS rules setup the layered images, clipped foreground, handle, divider, labels, and mobile sizing.

/* Reset */* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

/* Container: enforces 16:9 and clips overflow so images stay inside */.comparison-container {
  position: relative;
  width: 100%;
  max-width: 800px;
  aspect-ratio: 16/9;
  border-radius: 16px;
  overflow: hidden;
  box-shadow:
    0 20px 60px -20px rgba(0, 0, 0, 0.15),
    0 0 0 1px rgba(0, 0, 0, 0.05);
  background: #fff;
  user-select: none; /* Prevents text selection during drag */}

/* Both images fill the container absolutely */.image-wrapper {
  position: absolute;
  inset: 0;
  overflow: hidden;
}

.image-wrapper img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

/* Before image: sits above the after image; clip-path controls how much shows */.image-before {
  position: absolute;
  inset: 0;
  z-index: 1;
  clip-path: inset(0 50% 0 0); /* JS updates the 50% value on drag */}

/* After image: always fully visible underneath the before layer */.image-after {
  position: absolute;
  inset: 0;
  z-index: 0;
  filter: saturate(1.1) contrast(1.05); /* Slight enhancement on the "after" side */}

/* Labels: hidden by default, animate in on container hover */.label {
  position: absolute;
  top: 20px;
  padding: 8px 16px;
  background: rgba(0, 0, 0, 0.6);
  backdrop-filter: blur(10px);
  color: #fff;
  font-size: 0.875rem;
  font-weight: 600;
  border-radius: 100px;
  z-index: 2;
  opacity: 0;
  transform: translateY(-10px);
  transition: all 0.3s ease;
}

.comparison-container:hover .label {
  opacity: 1;
  transform: translateY(0);
}

.label-before { left: 20px; }
.label-after  { right: 20px; }

/* Slider line: a thin vertical divider with a faded gradient effect */.slider-line {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 50%; /* Moved by JS */  width: 2px;
  transform: translateX(-50%);
  z-index: 3;
  pointer-events: none;
}

.slider-line::before {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(
    to bottom,
    transparent 0%,
    rgba(255, 255, 255, 0.3) 15%,
    rgba(255, 255, 255, 0.9) 40%,
    rgba(255, 255, 255, 0.9) 60%,
    rgba(255, 255, 255, 0.3) 85%,
    transparent 100%
  );
}

/* Knob: the circular drag handle centered on the slider line */.slider-knob {
  position: absolute;
  top: 50%;
  left: 50%; /* Moved by JS */  width: 44px;
  height: 44px;
  transform: translate(-50%, -50%);
  background: #fff;
  border-radius: 50%;
  z-index: 4;
  cursor: grab;
  display: flex;
  align-items: center;
  justify-content: center;
  box-shadow:
    0 4px 20px rgba(0, 0, 0, 0.15),
    0 0 0 1px rgba(0, 0, 0, 0.05);
  transition:
    transform 0.2s ease,
    box-shadow 0.2s ease;
}

/* Knob press state: slight scale-down for tactile feel */.slider-knob:active {
  cursor: grabbing;
  transform: translate(-50%, -50%) scale(0.95);
  box-shadow:
    0 2px 10px rgba(0, 0, 0, 0.1),
    0 0 0 1px rgba(0, 0, 0, 0.05);
}

.slider-knob svg {
  width: 20px;
  height: 20px;
  color: #1a1a1a;
  opacity: 0.6;
}

/* Hover glow on the knob */.slider-knob::before {
  content: "";
  position: absolute;
  inset: -4px;
  border-radius: 50%;
  background: radial-gradient(circle, rgba(255, 255, 255, 0.8) 0%, transparent 70%);
  opacity: 0;
  transition: opacity 0.3s ease;
  z-index: -1;
}

.comparison-container:hover .slider-knob::before {
  opacity: 1;
}

/* Disable transitions during active drag for instant response */.comparison-container.dragging .image-before,
.comparison-container.dragging .slider-line,
.comparison-container.dragging .slider-knob {
  transition: none;
}

/* Smooth easing when not dragging */.image-before,
.slider-line,
.slider-knob {
  transition: left 0.1s ease-out;
}

/* Responsive: smaller knob and tighter radius on mobile */@media (max-width: 640px) {
  .comparison-container { border-radius: 12px; }
  .slider-knob { width: 36px; height: 36px; }
  .slider-knob svg { width: 16px; height: 16px; }
  .label { font-size: 0.75rem; padding: 6px 12px; }
}

3. Activate the image comparsion slider. The JavaScript below handles three event groups: mouse, touch, and keyboard. All three call the same updateSlider() function.

// Grab the container and the elements we update on every move
const container  = document.getElementById("myComparison");
const beforeImage = container.querySelector(".image-before");
const sliderLine  = document.getElementById("sliderLine");
const sliderKnob  = document.getElementById("sliderKnob");

let isDragging      = false;
let currentPosition = 50; // Starts at center (percentage)

// Core update function: clamps position and applies it to clip-path + element positions
function updateSlider(position) {
  position = Math.max(0, Math.min(100, position)); // Clamp to 0–100%
  currentPosition = position;

  // Clip the before image from the right, exposing only the left portion
  beforeImage.style.clipPath = `inset(0 ${100 - position}% 0 0)`;

  // Move the line and knob to the same horizontal position
  sliderLine.style.left = `${position}%`;
  sliderKnob.style.left = `${position}%`;
}

// Convert a clientX value to a percentage relative to the container
function handleMove(clientX) {
  const rect       = container.getBoundingClientRect();
  const x          = clientX - rect.left;
  const percentage = (x / rect.width) * 100;
  updateSlider(percentage);
}

// --- Mouse Events ---

// Dragging starts on the knob
sliderKnob.addEventListener("mousedown", (e) => {
  isDragging = true;
  container.classList.add("dragging"); // Disables CSS transitions for instant updates
  e.preventDefault(); // Prevents browser text selection during drag
});

// Clicking anywhere on the container also starts a drag from that point
container.addEventListener("mousedown", (e) => {
  if (e.target === sliderKnob) return; // Knob already handled above
  isDragging = true;
  container.classList.add("dragging");
  handleMove(e.clientX); // Jump to click position immediately
});

// Track pointer movement across the whole document so drag works outside the container
document.addEventListener("mousemove", (e) => {
  if (!isDragging) return;
  handleMove(e.clientX);
});

// Release drag state when the mouse button comes up anywhere on the page
document.addEventListener("mouseup", () => {
  isDragging = false;
  container.classList.remove("dragging");
});

// --- Touch Events ---

sliderKnob.addEventListener("touchstart", (e) => {
  isDragging = true;
  container.classList.add("dragging");
  e.preventDefault(); // Prevents page scroll during drag
}, { passive: false });

container.addEventListener("touchstart", (e) => {
  if (e.target === sliderKnob) return;
  isDragging = true;
  container.classList.add("dragging");
  handleMove(e.touches[0].clientX); // Use first touch point
}, { passive: false });

document.addEventListener("touchmove", (e) => {
  if (!isDragging) return;
  handleMove(e.touches[0].clientX);
}, { passive: false });

document.addEventListener("touchend", () => {
  isDragging = false;
  container.classList.remove("dragging");
});

// --- Keyboard Accessibility ---

// ARIA attributes turn the knob into a proper slider widget for assistive tech
sliderKnob.setAttribute("tabindex",    "0");
sliderKnob.setAttribute("role",        "slider");
sliderKnob.setAttribute("aria-label",  "Image comparison slider");
sliderKnob.setAttribute("aria-valuemin", "0");
sliderKnob.setAttribute("aria-valuemax", "100");
sliderKnob.setAttribute("aria-valuenow", "50");

sliderKnob.addEventListener("keydown", (e) => {
  let newPosition = currentPosition;

  switch (e.key) {
    case "ArrowLeft":
    case "ArrowDown":
      newPosition -= 5; // Move 5% left per keypress
      e.preventDefault();
      break;
    case "ArrowRight":
    case "ArrowUp":
      newPosition += 5; // Move 5% right per keypress
      e.preventDefault();
      break;
    case "Home":
      newPosition = 0;   // Jump to far left
      e.preventDefault();
      break;
    case "End":
      newPosition = 100; // Jump to far right
      e.preventDefault();
      break;
  }

  updateSlider(newPosition);
  sliderKnob.setAttribute("aria-valuenow", Math.round(currentPosition)); // Keep ARIA in sync
});

// Set initial position
updateSlider(50);

Alternatives:

The post JS Before/After Image Comparison Slider with Touch Support appeared first on CSS Script.

rssfeeds-admin

Share
Published by
rssfeeds-admin

Recent Posts

Apple Works on Fix for iPhone Passcode Bug Linked to Missing Czech Keyboard Character

Apple is reportedly developing a software fix for a frustrating iOS 26 bug that has…

2 hours ago

Researcher Uses Claude Opus to Build a Working Chrome Exploit Chain

Amidst the heated debate surrounding Anthropic’s recent announcement of its Mythos and Project Glasswing models,…

2 hours ago

Lee Cronin’s The Mummy Poster Complaints

A poster for Lee Cronin's The Mummy has drawn complaints for its depiction of a…

2 hours ago

Rockford fire: Lightning strike causes $200,000 in damages

A lightning strike started a Rockford office building on fire Friday night, resulting in an…

2 hours ago

A Look Back, April 18

200 Years Ago School in Southampton! Elizabeth Strong will open a school in the chamber…

3 hours ago

Former Hadley resident convicted after unleashing bees on deputies

SPRINGFIELD — A former Hadley woman who unleashed bees on Hampden County sheriff’s office workers…

3 hours ago

This website uses cookies.