Some time ago, I was on the Temporal
documentation and there was a comparison component showing the advantage of
the library. I told myself it would be a good component to implement. Here we go!
This component is perfect for multiple use cases: from “before and after” photos, smartphone’s camera comparison, comparison of codes for a library that removes boilerplate code, or any scenarios where you want to highlight changes.
Developing this component will be a piece of cake by the end of this article.
The UI is quite simple to implement. You have:
- a background image
- an image on the foreground in absolute position, that you constraint the width
- a slider in absolute position
<div class="wrapper">
<div class="rightImage"></div>
<div class="leftImage"></div>
<div class="slider"></div>
</div>
.wrapper {
position: relative;
}
.rightImage {
background-image: url('/rigtImage');
}
.leftImage {
background-image: url('/leftImage');
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 200px;
}
.slider {
position: absolute;
top: 0;
bottom: 0;
left: 200px;
}
Now that we have a static UI, it’s time to make it dynamic.
We want to some event listeners to the slider element.
The first event listener is on the pointerdown
event.
slider.addEventListener('pointerdown', () => {});
We want to change the left
position of the slider when the user pressed down its cursor on it and moves it.
So let’s add the event listener on pointermove
on the previous listener:
slider.addEventListener('pointerdown', () => {
document.addEventListener('pointermove', (e) => {
const { clientX } = e;
// Here we gonna calculate the next position of the
// slider and change it
});
});
I want to know the position of the slider in relation to its parent in percentage.
The calculation is not so complicated, but let’s draw it to have a better visualization of the problematic:
So the percentage of the position becomes:
const percent =
(100 * (clientX - containerX)) / containerWidth;
Unfortunately, if you just do this, the slider can be out of the container, so we need to min and max the value.
const percent =
(100 * (clientX - containerX)) / containerWidth;
// Can't be lower than 0%
const minBoundedPercent = Math.max(percent, 0);
// Can't be upper than 100%
const minAndMaxBoundedPercent = Math.min(
minBoundedPercent,
100,
);
The onMove
listener becomes:
document.addEventListener('pointermove', (e) => {
const { clientX } = e;
const { x: containerX, width: containerWidth } =
containerElement.getBoundingClientRect();
const percent =
(100 * (clientX - containerX)) / containerWidth;
const minBoundedPercent = Math.max(percent, 0);
const minAndMaxBoundedPercent = Math.min(
minBoundedPercent,
100,
);
// Function that updates the UI, in a declarative
// framework function to update the percent state
setPercent(minAndMaxBoundedPercent);
});
The last thing we need to handle in the pointerup
event.
This listener needs to be added at the same time than pointermove
event listener, and the logic of this listener
to remove every listeners (even itself).
slider.addEventListener('pointerdown', () => {
const onPointerMove = (e) => {};
document.addEventListener('pointermove', onPointerMove);
const onPointerUp = () => {
// Let's remove `pointermove` and `pointerup` event listeners
// DO NOT remove the `pointerdown` event listener, otherwise
// you won't be able to move the slider again
document.removeEventListenre(
'pointermove',
onPointerMove,
);
document.removeEventListenre('pointerup', onPointerUp);
};
document.addEventListener('pointerup', onPointerUp);
});
If you try this code and play with the component on a touch device, you will see that it won’t work :(
This is because the mobile browser canceled the pointer thanks to the touch-action
CSS property.
You can see this behavior by adding pointercancel
event listener ;)
We could remove this touch-action
behaviors by setting touch-action: none;
. But, we would have
a bad accessibility.
The solution is to add a touchmove
event listener. The logic inside will be the same than the pointermove
one.
But to get the clientX
value is different
const onTouchMove = (e) => {
const clientX = e.touches[0].clientX;
};
The component is now working on mobile, but what about power users that never uses a mouse?
It’s time to add keyboard management. The solution, I decided to implement is to use an input
of type range
, with a value from 0 to 100.
<input
type="range"
min="{0}"
max="{100}"
value="{percent}"
/>
Let’s add the event listener:
rangeInput.addEventListener('change', (e) => {
// Function that updates the UI, in a declarative
// framework function to update the percent state
setPercent(Number(e.target.value));
});
And now you can see the slider move :)
If you stop here you will have a bad UI, the input is visible and then you would want to display an outline on the slider circle.
.input {
opacity: 0;
}
To ease the development of the outline I put the input
before the circle element:
<input type="range" {...otherAttributes } />
<div class="circle"></div>
input:focus + .circle {
outline: 3px solid red;
}
And here we get a nice outline on the circle giving the user some feedback on active element:
And here you are the master wizard of comparison. The key points are:
- think to touch device where
onmove
event listener won’t work. So, you need to usetouchmove
event listener. - use of an
input
typerange
to ease the use of the component for power users.
It would be able to throttle the onmove
event listener to optimize performance.
If you are interested to see the full implementation made with native Javascript:
You can find me on Twitter if you want to comment this post or just contact me. Feel free to buy me a coffee if you like the content and encourage me.