Go to main content
October 31, 2023
Cover image

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
You can interact with the element! Tap to view
<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:

Window
containerWidth
Container
containerX
clientX

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 use touchmove event listener.
  • use of an input type range 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.