A couple of years ago, I wrote a post about building a before and after image slider. That solution still works great, but the one I'm going to go over in this post is much simpler and requires almost no JavaScript.

The markup for this slider looks very similar to the example from a couple of years ago. The key difference is the use of <input type="range" step="1" />. We'll use this to control the position of the slider.

html

<div class="wrapper">
  <div class="before">
	<img src="https://meedyuh.zachpatrick.com/light-mode.webp" />
  </div>
  
  <div class="after">
	<img src="https://meedyuh.zachpatrick.com/dark-mode.webp" />
  </div>
  
  <input type="range" step="1" />
</div>

In the earlier version of the slider I had added <span class="handle draggable"></span> to control the sliding. Using the range input allows us to significantly cut down on the amount of JavaScript we need.

Next, we can turn the .wrapper into a grid and add grid-area: 1 / 1 to its children. This will ensure everything starts in the top-left corner of the grid.

css

.wrapper {
  display: grid;

  > * {
    grid-area: 1 / 1;
  }
}

Then we can add styles to the images.

css

.wrapper {
  /* make sure our images are the same size */
  img {
    height: 400px;
	width: 100%;
	object-fit: cover;
  }
  
  .before {
    mask: linear-gradient(to right, black 0, var(--pos, 50%), transparent 0);
  }
  
  .after {
    mask: linear-gradient(to right, transparent 0, var(--pos, 50%), black 0);
  }
}

The key to this solution is the linear-gradient we use with the mask on the before and after containers. The mask property allows us to hide part of the image and we can use the linear-gradient property to specify what part of each image should be hidden.

css

mask: linear-gradient(to right, black 0, var(--pos, 50%), transparent 0);

So for the mask we're using on the before image, we're basically saying: this image should start out visible and stay visible until it reaches the position of the input range, then hide the rest of the image.

We're going to specify the value of --pos later, but in the meantime we're using a fallback of 50% to match the default position of the range input.

Now we can add some styles to the range input to make it pretty and make sure it appears above both images.

css

input[type='range'] {
  width: 100%;
  z-index: 1;
  appearance: none;
  background: transparent;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;

  /* styling for webkit (safari) and / blink (chrome) */
  &::-webkit-slider-thumb {
    appearance: none;
	height: 40px;
	width: 40px;
	background: oklch(0.8653 0.1781 96.43);
	cursor: pointer;
	
	background-repeat: no-repeat;
	background-position: center;
	background-size: 80%;
	background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 640 640'%3E%3Cpath fill='currentColor' d='M603.4 331.3L614.7 320L603.4 308.7L491.4 196.7L480.1 185.4L457.5 208L468.8 219.3L553.5 304L86.8 304L171.5 219.3L182.8 208L160.2 185.4L148.9 196.7L36.9 308.7L25.6 320L36.9 331.3L148.9 443.3L160.2 454.6L182.8 432L171.5 420.7L86.8 336L553.5 336L468.8 420.7L457.5 432L480.1 454.6L491.4 443.3L603.4 331.3z'/%3E%3C/svg%3E");
  }
  
  /* all the same stuff for firefox */
  &::-moz-range-thumb {
    appearance: none;
	height: 40px;
	width: 40px;
	background: oklch(0.8653 0.1781 96.43);
	cursor: pointer;
	
	background-repeat: no-repeat;
	background-position: center;
	background-size: 80%;
	background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 640 640'%3E%3Cpath fill='currentColor' d='M603.4 331.3L614.7 320L603.4 308.7L491.4 196.7L480.1 185.4L457.5 208L468.8 219.3L553.5 304L86.8 304L171.5 219.3L182.8 208L160.2 185.4L148.9 196.7L36.9 308.7L25.6 320L36.9 331.3L148.9 443.3L160.2 454.6L182.8 432L171.5 420.7L86.8 336L553.5 336L468.8 420.7L457.5 432L480.1 454.6L491.4 443.3L603.4 331.3z'/%3E%3C/svg%3E");
  }
}

Unfortunately you cannot combine vendor-specific pseudo-elements with a comma. They must be in separate CSS rules for the styles to be applied correctly in each browser, so we have to repeat the styles for each vendor.

Finally, we'll update the --pos property that we're using to determine the position of the slider. Since we're using a range input, all we have to do is listen for that to change and update the variable accordingly. So much easier than the solution in my older post!

javascript

document.querySelector('.wrapper > input[type="range"]').addEventListener('input', e => {
	document.querySelector('.wrapper').style.setProperty('--pos', e.target.value + "%");
})

Now we have a fully functioning before and after slider! In addition to being much easier to build, this new slider also takes care of a lot of accessibility concerns by default. Since we're using an HTML input, we don't have to worry about explicitly making it focusable for keyboard users or making sure it can be used by mobile users. It just works!