Need mock data for your designs or tests? Get it instantly with no setup required!
How to Build an Image Comparison Slider
Using React hooks to build a before and after image comparison component.
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!
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 befalse
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
andpointer-events
properties. In addition to thedraggable
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.
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 theuseEffect
that does so viaremoveEventListener
.
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 thereact-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 theclipPath
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. TheclipPath
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.
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]);
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.