CSS Before and After Image Slider

A draggable before and after image comparison built with resize and overflow, plus toggle and hover variants.

Published September 6, 2026 Intermediate 8 min read

A CSS before and after image slider stacks two versions of the same photo and lets the reader decide how much of each to see. The whole thing rests on one unusual property: resize, which browsers already ship for textareas and will apply to any element whose overflow is not visible. Make the top layer resizable, clip it, and dragging its edge wipes one image across the other.

No script is involved and no library is loaded. What that buys is a comparison that works before any JavaScript has parsed, and what it costs is control over the drag handle, because a browser will only put its resize grip in the bottom right corner. The two variants below take different trades: a button that swaps the images outright, and a wipe that runs on hover.

Drag the corner handle

A harbour at dusk, edited: warm color and raised contrast
The same harbour photograph unedited, in flat grayscale
Before After

drag the handle in the bottom right corner

HTML

<div class="ba-frame">
  <img class="ba-img" src="harbour.jpg" alt="Harbour at dusk, edited">

  <div class="ba-before">
    <img class="ba-img" src="harbour.jpg" alt="The same harbour, unedited">
  </div>

  <span class="ba-tag ba-tag-before">Before</span>
  <span class="ba-tag ba-tag-after">After</span>
</div>

CSS

/* One width for the frame and both images. A percentage inside the
   resizable pane would resolve against the pane, not the frame, and
   the two halves would stop lining up. */
.ba-frame {
  --ba-w: 480px;
  --ba-h: 300px;
  position: relative;
  width: var(--ba-w);
  height: var(--ba-h);
  max-width: 100%;
  overflow: hidden;
  border-radius: 12px;
  border: 1px solid #2a2a2d;
}

.ba-img {
  display: block;
  width: var(--ba-w);
  height: var(--ba-h);
  object-fit: cover;
}

/* overflow does two jobs: it clips the top image, and it is the
   condition resize needs before the browser draws a grip at all */
.ba-before {
  position: absolute;
  top: 0;
  left: 0;
  height: 100%;
  width: 55%;
  min-width: 28px;
  max-width: 100%;
  overflow: hidden;
  resize: horizontal;
}

/* the before state is a filter on the same file, so the layers
   cannot drift out of alignment */
.ba-before .ba-img {
  filter: grayscale(1) contrast(.88) brightness(1.04);
}

/* the divider rides the pane's own edge, so it moves with the drag */
.ba-before::after {
  content: "";
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  width: 3px;
  background: #b8ff57;
  pointer-events: none;
}

/* the browser's grip is always in the bottom right corner, so the
   visible knob is drawn there and nowhere else */
.ba-before::before {
  content: "";
  position: absolute;
  right: 3px;
  bottom: 3px;
  width: 16px;
  height: 16px;
  border-radius: 4px;
  background: #b8ff57;
  pointer-events: none;
  z-index: 1;
}

.ba-tag {
  position: absolute;
  top: 10px;
  font-family: ui-monospace, monospace;
  font-size: .6rem;
  letter-spacing: .08em;
  text-transform: uppercase;
  padding: .2rem .5rem;
  border-radius: 999px;
  background: rgba(12, 12, 13, .72);
  color: #f0f0f0;
  pointer-events: none;
}

.ba-tag-before { left: 10px; }
.ba-tag-after  { right: 10px; }

Other ways to build it

A button that swaps the two

This is the version to ship when the comparison carries information rather than decoration. Both images are stacked and always painted, and opacity decides which one is seen, so the change can be transitioned instead of snapping. The control is a real checkbox with a real label, which means it takes focus, toggles with the space bar, and announces its state. It gives up the partial view that dragging allows, and in exchange it works with a keyboard, on a touch screen, and in iOS Safari where resize does nothing.

A room photographed before the work, in flat grayscale
The same room after the work, in full color

HTML

<input type="checkbox" id="swap" class="ba-check">
<label for="swap" class="ba-btn">Show the edited version</label>

<div class="ba-frame ba-swap-frame">
  <div class="ba-layer ba-layer-before">
    <img class="ba-img" src="room.jpg" alt="The room before the work">
  </div>
  <div class="ba-layer ba-layer-after">
    <img class="ba-img" src="room.jpg" alt="The same room after the work">
  </div>
</div>

CSS

/* clipped rather than display: none, which would take the control
   out of the accessibility tree and leave the demo mouse-only */
.ba-check {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.ba-btn {
  display: inline-block;
  padding: .55rem 1.15rem;
  background: #1c1c1e;
  color: #f0f0f0;
  border: 1px solid #2a2a2d;
  border-radius: 8px;
  font-size: .85rem;
  cursor: pointer;
  user-select: none;
  transition: background .2s, color .2s, border-color .2s;
}

.ba-check:checked + .ba-btn {
  background: #b8ff57;
  color: #0c0c0d;
  border-color: #b8ff57;
}

.ba-check:focus-visible + .ba-btn {
  outline: 2px solid #b8ff57;
  outline-offset: 2px;
}

/* both layers stay painted and opacity picks the winner, so the
   change can be transitioned; display would snap */
.ba-swap-frame .ba-layer {
  position: absolute;
  inset: 0;
  transition: opacity .35s;
}

.ba-swap-frame .ba-layer-after  { opacity: 0; }

.ba-check:checked ~ .ba-swap-frame .ba-layer-after  { opacity: 1; }
.ba-check:checked ~ .ba-swap-frame .ba-layer-before { opacity: 0; }

.ba-swap-frame .ba-layer-before .ba-img {
  filter: grayscale(1) contrast(.88) brightness(1.04);
}

A wipe on hover

Here the revealing pane animates its width from zero to full, which reads as an edge travelling across the photo rather than one image fading into another. It is the cheapest of the three to build and the least capable: there is no partial position, and on a touch screen there is no hover state to enter at all. A hover: none media query leaves the pane part way open on those devices so the comparison is still visible, and :focus-visible plus a tabindex give the keyboard a way in.

HTML

<div class="ba-frame ba-wipe-frame" tabindex="0" role="img"
     aria-label="A street scene, unedited, which changes to the
                 edited version on hover or focus">
  <div class="ba-base">
    <img class="ba-img" src="street.jpg" alt="">
  </div>
  <div class="ba-wipe-pane">
    <img class="ba-img" src="street.jpg" alt="">
  </div>
</div>

CSS

/* width, not opacity: an edge travelling across the photo reads as
   a wipe, a cross fade reads as two pictures swapping */
.ba-wipe-frame .ba-wipe-pane {
  position: absolute;
  top: 0;
  left: 0;
  height: 100%;
  width: 0;
  overflow: hidden;
  transition: width .5s;
}

.ba-wipe-frame:hover .ba-wipe-pane,
.ba-wipe-frame:focus-visible .ba-wipe-pane {
  width: 100%;
}

.ba-wipe-frame:focus-visible {
  outline: 2px solid #b8ff57;
  outline-offset: 3px;
}

.ba-wipe-frame .ba-base .ba-img {
  filter: grayscale(1) contrast(.88) brightness(1.04);
}

/* a touch screen never enters :hover, so the pane is left part way
   open there instead of hidden for good */
@media (hover: none) {
  .ba-wipe-frame .ba-wipe-pane {
    width: 55%;
    transition: none;
  }
}

How it works

Two copies of the same image sit in a fixed size frame. The lower one is untouched. The upper one lives inside a pane with overflow: hidden and resize: horizontal, so the browser draws a grip on it and dragging that grip changes the pane's width. Because the pane clips rather than scales, widening it reveals more of the top image and narrowing it reveals more of the one underneath. The before treatment is a filter, not a second file, so the demo needs one image rather than two and the two halves are guaranteed to line up. A ::after on the pane paints the divider along its right edge, which means the divider travels with the drag for free.

The sizing is the part that decides whether this works. Both images have to be exactly the same width, and a percentage width on the top image would resolve against the resizable pane rather than the frame, so it would shrink as the pane narrowed and the photo would no longer line up across the divider. The fix is a fixed frame: a custom property holds the width, the frame and both images use it, and a media query swaps in a smaller number on narrow screens. This is the honest constraint of the technique rather than a shortcut, because CSS has no way to say "as wide as my grandparent" here.

resize is doing something slightly unusual. It exists for textareas, but the specification applies it to any element with a computed overflow other than visible, which is why the same declaration that clips the image is also what makes the drag possible. Remove overflow: hidden and two things break at once: the grip disappears and the top image spills across the whole frame.

The grip's position is not negotiable. A browser draws it in the bottom right corner of the resizable box, and nothing in CSS moves it. That rules out the centered circular handle these sliders usually have, and it is worth knowing before choosing this over a scripted version. The compromise here is to draw the visible knob exactly where the grip already is, so the affordance and the interaction are in the same place. Drawing a handle halfway down the divider would look like the thing to drag and would do nothing.

The before state is a filter on the top image: grayscale(1) with a small contrast and brightness adjustment so it reads as an unedited photo rather than just a desaturated one. Filters run on the compositor, so this costs nothing at drag time. It also keeps the demo to one network request, and more usefully it means the two layers can never drift out of alignment, which is the most common way a two file comparison goes wrong.

There is a real accessibility gap here worth stating plainly rather than burying. A resizable pane is not focusable and not operable from a keyboard, so this main version is a mouse and touch interaction only. That is why the first variant exists: a checkbox and a label give the same comparison a control that tabs, toggles with the space bar, and announces itself. If only one version ships, it should be that one.

CSS properties used

resize
Turns the top pane into something the reader can drag. It only applies when overflow is not visible, and the grip always lands in the bottom right corner.
overflow
hidden does two jobs at once here: it clips the top image to the pane's width, and it is the condition resize needs before a grip appears at all.
filter
grayscale(1) makes the before state out of the same file as the after state, so the two layers cannot drift out of alignment.
object-fit
cover fills the frame at a fixed aspect ratio without squashing the photo, whatever the source image's proportions are.
custom properties
One frame width, used by the frame and both images. A percentage would resolve against the resizable pane and break the alignment.
transition
Drives the swap and the wipe in the two variants. The main version needs none, because the reader is moving it.
:checked
Reads the hidden checkbox in the swap variant, which is what gives that version a keyboard-operable control.

Browser support

FeatureChromeFirefoxSafariEdge
resize44479
filter1835679
object-fit32361079
transition455.112
custom properties49311016
:focus-visible86415.486

Support for the parts is old and even, so the technique itself is safe. The exception worth knowing is iOS Safari, which has historically ignored resize on elements other than form controls, and there the pane simply stays at its starting width. That degrades to a fixed split rather than a broken layout, and it is another reason to prefer the checkbox variant when the comparison actually matters. clip-path: inset(50%) is used only to hide the checkbox visually and has no bearing on the comparison itself.

Accessibility notes

The draggable version is not keyboard operable. A resizable pane takes no focus, has no role, and offers nothing to a screen reader beyond the two images inside it. Calling that an accessible slider would be wrong, so the page does not. Where the comparison carries real information, ship the checkbox variant, which is a labelled control that tabs, toggles with the space bar, and announces its state.

Both images need real alt text, and they need different alt text. "Before" and "after" alone say nothing to somebody who cannot see either one. Describe what actually changed, for example "the same kitchen with the cabinets painted white", so the comparison survives without the pictures. If the pair is purely decorative, alt="" on both is the honest answer.

The hover variant is unreachable on a touch screen, where there is no hover state to enter. The stylesheet handles that with a hover: none media query that leaves the pane part way open, so the comparison is still visible rather than permanently hidden. The same variant also responds to :focus-visible so the keyboard can reach it.

What you can build with it

  • Photo editing. The original next to the graded version, which is where this pattern comes from and the case the filter approach models directly.
  • Renovation and repair. A room, a roof or a restored car photographed from the same position before and after the work.
  • Product retouching. A raw studio shot against the version that ships on the listing, which is useful on an about page where a claim about craft needs evidence.
  • Map and satellite comparison. The same coordinates in two years, where the fixed frame requirement is a feature rather than a nuisance because the alignment has to be exact.
  • Design case studies. An old interface wiped across the redesign, paired with something like the scroll reveal so each comparison arrives as the reader reaches it.

Mistakes worth avoiding

  • Sizing the top image with a percentage. It resolves against the resizable pane, so the photo shrinks as the pane narrows and the two halves stop lining up. Both images need the same fixed width, which is what the custom property is for.
  • Dropping overflow: hidden from the pane. The grip vanishes and the top image spills across the whole frame, and it looks like resize is unsupported when it is only unmet.
  • Drawing a round handle halfway down the divider. The grip is in the corner and cannot be moved, so a centered knob is an affordance pointing at nothing, and people will drag it and conclude the demo is broken.
  • Using two different image files. Any difference in dimensions or crop shows up as a jump at the divider, which is the failure this version avoids by filtering one file instead.
  • Treating the draggable version as accessible. It cannot be focused or operated without a pointer, so it needs the checkbox variant beside it or in place of it.

Frequently asked questions

Can you make a before and after slider without JavaScript?
Yes. Put the top image in a pane with overflow: hidden and resize: horizontal, and the browser's own resize grip becomes the slider. The catch is that the grip sits in the bottom right corner and CSS cannot move it, so it will not look like the centered handle a scripted slider has.
Why does my second image get smaller as I drag?
Its width is a percentage, and percentages resolve against the resizable pane rather than the frame around it. Give both images the same fixed width, ideally from one custom property, so the top layer is clipped rather than scaled.
Can the resize handle be moved or restyled?
Not with CSS. Its position and appearance belong to the browser. You can draw your own graphic in the same corner so the visible handle and the real one agree, which is what this example does, but a handle anywhere else will not be draggable.
Does this work on iPhone?
The drag does not. iOS Safari has historically applied resize only to form controls, so the pane keeps its starting width and the comparison becomes a fixed split. It fails to a readable state rather than a broken one, and the checkbox variant works everywhere.
Is a CSS image comparison accessible?
The draggable version is not. It takes no focus and exposes no state. The checkbox variant is, because a real control drives it, and it is the one to use when the comparison carries information rather than decoration.