How to Build an Image Comparison Slider

How to Build an Image Comparison Slider

Today we'll be building an image comparison slider component using React. This kind of UI treatment works well for comparing before and after, or side by side images.

The component we'll build is responsive and allows for touch events and basic keyboard support. We'll use several React hooks, including useEffect, useState, useRef and useCallback, so familiarity with hooks is helpful, but not required!

end result of comparison slider

Getting Started#

We will start by creating a new file named ComparisonSlider.jsx. This component will take two props: an object to describe the image that displays on top – topImage, and an object for the one on the bottom –bottomImage.

Each object should contain an image src and alt value.

{
	src: 'path/to/image.jpg',
	alt: 'image alt text',
}

We also want to track a single state value, isResizing, which will determine if the image is being resized or not.

import React, { useState, useEffect, useRef, useCallback } from 'react';

const ComparisonSlider = ({
  topImage,
  bottomImage,
}) => {
	const [isResizing, setIsResizing] = useState(false);

	return ...
}

export default ComparisonSlider;

In addition to the state value, we need two refs to get the positioning data from the HTML elements. These refs are topImageRef and handleRef.

const ComparisonSlider = ({
  topImage,
  bottomImage,
}) => {
	const [isResizing, setIsResizing] = useState(false);
  const topImageRef = useRef();  const handleRef = useRef();
	return ...
}

Now for the component markup, and we'll start with the slider handle. This is what the user drags left or right to reveal the bottom image beneath the top image. The handle displays an icon to show that it can be moved left or right (CompareIcon).

import { ReactComponent as CompareIcon } from './assets/compare.svg';

const ComparisonSlider = ({
  topImage,
  bottomImage,
}) => {
	...
  ...
	return (	  <div className="comparison-slider">	    <div ref={handleRef} className="handle">	      <CompareIcon />	    </div>	  </div>	);
}

Next we have the images.

return (
  <div className="comparison-slider">
    <div ref={handleRef} className="handle">
      <CompareIcon />
    </div>
    <div ref={topImageRef} className="comparison-item top">      <img				draggable="false"				src={topImage.src}				alt={topImage.alt}			 />    </div>    <div className="comparison-item">      <img				draggable="false"				src={bottomImage.src}				alt={bottomImage.alt}			 />    </div>  </div>
);

Notice the draggable attribute on the images. It's set to be false to ensure there won't be any "ghosting" when the images are dragged with a mouse. You can read more about it here.

With the markup out of the way, let's add some CSS.

First we need to style the images themselves.

img {
  width: 100%;
  height: auto;
  vertical-align: middle;
  -moz-user-select: none;
  -webkit-user-select: none;
  user-select: none;
  pointer-events: none;
}

We are making use of the user-select and pointer-events properties. In addition to the draggable attributes we just added to the images, this will prevent them from being highlighted or dragged by mouse events.

There are also some styles for the comparison slider and items.

.comparison-slider {
  margin: 50px auto;
  max-width: 800px;
  position: relative;
  overflow: hidden;
  box-shadow: 0 15px 15px rgb(0 0 0 / 25%);
}

.comparison-item {
  overflow: hidden;
}

.comparison-item.top {
  z-index: 1;
  position: absolute;
  height: 100%;
  width: 100%;
  top: 0;
}

.comparison-item.top img {
  height: 100%;
  object-fit: cover;
}

The top image is absolutely positioned over the bottom one, and it uses the object-fit property to maintain the image aspect ratio. Try with or without – you'll see a difference!

Finally, there are some styles for the handle.

.handle {
  position: absolute;
  width: 3px;
  height: 100%;
  background: white;
  z-index: 3;
  cursor: col-resize;
  top: 0;
}

.handle svg {
  display: block;
  position: absolute;
  top: calc(50% - 15px);
  right: calc(50% - 15px);
  height: 30px;
  width: 30px;
  background: white;
  border-radius: 8px;
  padding: 2px;
}

Believe it or not, this is the only CSS we need! So far, you should have the top image displaying over the bottom image, and the handle off to the left edge.

markup and styles completed

Now let's add some events!

Handle Drag Events#

The first thing we need to determine is when the handle is starting to be dragged so that we can set up a series of events. This can be done by adding an onDragStart event handler to the handle element.

When a dragging interaction first begins, we update the isResizing state to be true.

return (
  <div className="comparison-slider">
    <div
      ref={handleRef}
      className="handle"
      onMouseDown={() => setIsResizing(true)}    >
      <CompareIcon />
    </div>
    ...
  </div>
)

Now we need to react to this state change. For that, add a useEffect hook.

Within this hook, we add some event listeners if the value of isResizing is true.

useEffect(() => {
  if (isResizing) {
    window.addEventListener('mousemove', handleResize);
    window.addEventListener('mouseup', handleResizeEnd);
  }

  return () => {
    window.removeEventListener('mousemove', handleResize);
    window.removeEventListener('mouseup', handleResizeEnd);
  }
}, [isResizing, handleResize, handleResizeEnd]);

The first listener, handleResize, runs when the handle is being dragged (mousemove). The second event listener, handleResizeEnd will run when the drag has ended (mouseup).

As part of the useEffect hook cleanup, you should remove any event listeners when the component unmounts. Return a function from the useEffect that does so via removeEventListener.

Next we need to write three new functions to support the events added above. First there's handleResize.

const handleResize = useCallback((e) => {
  setPositioning(e.clientX);
}, [setPositioning]);

This new function will call another function, setPositioning, and pass it the clientX position of the mousemove event.

You'll notice that we use the useCallback hook to wrap most of our functions. This is to help with performance, and also to satisfy the react-hooks/exhaustive-deps ESLint rule. useCallback is outside the scope of this article, but if you aren't familiar you can read more about it here.

Next, let's create the setPositioning function we just referenced. It will update the positioning of both the handle and the top image when the handle is being dragged. It takes an x positioning value as a parameter.

const setPositioning = useCallback((x) => {
  const { left, width } = topImageRef.current.getBoundingClientRect();
  const handleWidth = handleRef.current.offsetWidth;
}, []);

First, get the left and width values from the topImageRef via getBoundingClientRect(). This gives us the width of the top image, and tell us how far from the left edge of the screen it is. We also use the offsetWidth value of handleRef to save the width of the handle to a constant.

Now, we have some calculations to determine if the x position of the drag event is within the top image.

const setPositioning = useCallback((x) => {
  const { left, width } = topImageRef.current.getBoundingClientRect();
  const handleWidth = handleRef.current.offsetWidth;

  if ((x >= left) && (x <= width + left - handleWidth)) {    handleRef.current.style.left = `${(x - left) / width * 100}%`;    topImageRef.current.style.clipPath =      `inset(0 ${100 - (x - left) / width * 100}% 0 0)`;  }}, []);

The x value should be greater than or equal to the left edge, and less than or equal to the width, plus the left edge, minus the handleWidth value.

If this criteria is true, then we update the style property of both handleRef and topImageRef.

For the topImageRef, you'll see that we're updating the clipPath property. This is the "secret sauce" that drives this resize interaction! Since we can't update the width of the image, we need a way to "crop" it horizontally. The clipPath property does just that.

Finally we have our third function, handleResizeEnd. This function will handle some event cleanup once the drag event ends. It will also set the isResizing state back to false.

const handleResizeEnd = useCallback(() => {
  setIsResizing(false);
  window.removeEventListener('mousemove', handleResize);
  window.removeEventListener('mouseup', handleResizeEnd);
}, [handleResize]);

With these events completed, you should now be able to drag the handle over the bottom image top reveal the top image.

drag events in progress

However, you will notice that the interaction isn't quite right. The handle displays off to the left edge, and the top image flickers into view when the handle is dragged.

This is because we aren't setting any default positioning for the handle or the top image. We can add a useEffect to do this when the component mounts.

// Set initial positioning on component mount
useEffect(() => {
  const { left, width } = topImageRef.current.getBoundingClientRect();
  const handleWidth = handleRef.current.offsetWidth;

  setPositioning((width / 2 + left) - (handleWidth / 2));
}, [setPositioning]);
functioning comparison component

Adding Keyboard Support#

We have our slider working now, but there are some additional improvements that we could make. The first being basic keyboard support. A user without a mouse should be able to use the arrow keys to move the handle back and forth just as they would with a mouse. Adding this is fairly easy.

In our ComparisonSlider component, create a new function named onKeyDown.

const onKeyDown = useCallback((e) => {
  const {
    offsetLeft,
    offsetParent,
  } = handleRef.current;

  if (e.code === 'ArrowLeft') {
    setPositioning(offsetLeft + offsetParent.offsetLeft - 10);
  }

  if (e.code === 'ArrowRight') {
    setPositioning(offsetLeft + offsetParent.offsetLeft + 10);
  }
}, [setPositioning]);

Within this function, we check the value of e.code and update the position by 10 to the right or left, depending on if the left arrow or right arrow key was pressed.

Then we need an event listener for this event when the component mounts. We can do so via another useEffect.

// Add keydown event on mount
useEffect(() => {
  window.addEventListener('keydown', onKeyDown);
}, [onKeyDown]);

With this change in place, pressing and holding the left or right arrows should move the drag handle back and forth.

Don't forget to update the useEffect we added earlier to clean up this new event when the component unmounts.

useEffect(() => {
  if (isResizing) {
    window.addEventListener('mousemove', handleResize);
    window.addEventListener('mouseup', handleResizeEnd);
  }

  return () => {
    window.removeEventListener('mousemove', handleResize);
    window.removeEventListener('mouseup', handleResizeEnd);
    window.removeEventListener('keyup', onKeyDown);  }
}, [isResizing, handleResize, handleResizeEnd, onKeyDown]);

Adding Touch Events#

The final addition we should make to our comparison slider component is to enable touch interactions. We already have events for mousemove and mouseup, but we need touchmove and touchend events to support tablets and mobile devices.

First, add an onTouchStart event to the handle element. It will update the isResizing state just like onMouseDown.

<div
  ref={handleRef}
  className="handle"
  onMouseDown={() => setIsResizing(true)}
  onTouchStart={() => setIsResizing(true)}>
  <CompareIcon />
</div>

Next, update the main useEffect to add the event listeners for these new events, as well as the return function to remove them.

useEffect(() => {
  if (isResizing) {
    window.addEventListener('mousemove', handleResize);
    window.addEventListener('touchmove', handleResize);    window.addEventListener('mouseup', handleResizeEnd);
    window.addEventListener('touchend', handleResizeEnd);  }

  return () => {
    window.removeEventListener('mousemove', handleResize);
		window.addEventListener('touchmove', handleResize);    window.removeEventListener('mouseup', handleResizeEnd);
		window.removeEventListener('touchend', handleResizeEnd);    window.removeEventListener('keyup', onKeyDown);
  }
}, [isResizing, handleResize, handleResizeEnd, onKeyDown]);

We also need to update the handleResize function. Working with touch events is different than mouse events, so we need to add some conditional logic.

const handleResize = useCallback((e) => {
  if (e.clientX) {
    setPositioning(e.clientX);
  } else if (e.touches[0] && e.touches[0].clientX) {
    setPositioning(e.touches[0].clientX);
  }
}, [setPositioning]);

If we are working with mouse events, a clientX value exists on the event. But a touch event will include an additional touches array with the positioning data we need. Inside that array, we can access the clientX value as e.touches[0].clientX.

Last, update the handleResizeEnd function to remove the events we added when the touchend event occurs.

const handleResizeEnd = useCallback(() => {
  setIsResizing(false);

  window.removeEventListener('mousemove', handleResize);
  window.removeEventListener('touchmove', handleResize);  window.removeEventListener('mouseup', handleResizeEnd);
  window.removeEventListener('touchend', handleResizeEnd);}, [handleResize]);

Now our component should work on devices with touch events!

Summary#

In this article, you learned how to build an image comparison slider component in React, using React hooks. We were able to do this in just over 100 lines of JavaScript and 60 lines of CSS! The CSS clip-path property came in handy and it helped us achieve the "cropping" effect we were looking for.