---
name: before-after-slider
description: Use when you want "Useful, professional, clear" - Lets users drag between two versions of an image.
---

# Before/After Slider

> **Category:** Image / Media  -  **Personality:** Useful, professional, clear
>
> **Best use:** Design, restoration, SaaS demos
>
> **Ratings:** Professional 5/5 - Casual 3/5 - Exotic 1/5 - Visual impact 3/5 - Difficulty 3/5 - Performance cost 2/5

## What it is

A draggable divider laid over a single image that reveals a "before" version on one side and an "after" version on the other. The trick is that **both layers are the same image** — the "before" copy is simply CSS-filtered (here: faded, desaturated, warmed to mimic an ungraded camera RAW) and clipped to the divider. It is the clearest way to communicate a transformation — a colour grade, a restoration, a renovation, an AI upscale — because the eye compares the *exact same pixels* before and after. Reach for it in editing tools, photo-restoration services, and SaaS "look what we did" demos.

## Dependencies / CDN

None - pure CSS + vanilla JS. The demo loads two Google Fonts purely for styling (optional):

```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Outfit:wght@400;500;600&display=swap" rel="stylesheet">
```

## HTML

Two stacked copies of one image, plus a divider and a focusable handle that carries the slider semantics:

```html
<div class="bas-compare" id="basCompare">
  <div class="bas-layer bas-after">
    <img src="coast.jpg" alt="Coastline, fully colour-graded" draggable="false">
  </div>
  <div class="bas-layer bas-before" aria-hidden="true">
    <img src="coast.jpg" alt="" draggable="false">      <!-- same file, gets a filter -->
  </div>

  <span class="bas-tag bas-tag-before">Before · Camera RAW</span>
  <span class="bas-tag bas-tag-after">After · Graded</span>

  <div class="bas-divider" aria-hidden="true"></div>
  <div class="bas-handle" id="basHandle" role="slider" tabindex="0"
       aria-label="Before / after comparison"
       aria-valuemin="0" aria-valuemax="100" aria-valuenow="50"
       aria-valuetext="Divider at 50 percent — original left, graded right.">
    <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor"
         stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
      <path d="M10 7.5 5.5 12 10 16.5"/><path d="M14 7.5 18.5 12 14 16.5"/>
    </svg>
  </div>
</div>
```

## CSS

```css
/* one shared variable drives the clip, the divider and the handle */
.bas-compare{position:relative;border-radius:16px;overflow:hidden;aspect-ratio:3/2;
  --bas-pos:50%; cursor:ew-resize; touch-action:none; user-select:none}
.bas-layer{position:absolute;inset:0}
.bas-layer img{width:100%;height:100%;object-fit:cover;display:block;
  pointer-events:none;-webkit-user-drag:none}

/* THE EFFECT: the "before" copy sits on top, clipped to the divider position.
   clip-path hides part of the element WITHOUT resizing the image (no squish). */
.bas-before{clip-path:inset(0 calc(100% - var(--bas-pos)) 0 0)}
.bas-before img{filter:grayscale(.62) saturate(.55) contrast(.83) brightness(1.07) sepia(.34)}

.bas-divider{position:absolute;top:0;bottom:0;left:var(--bas-pos);width:2px;margin-left:-1px;
  pointer-events:none;background:rgba(255,255,255,.96);box-shadow:0 0 16px rgba(0,0,0,.55)}

.bas-handle{position:absolute;top:50%;left:var(--bas-pos);transform:translate(-50%,-50%);
  width:50px;height:50px;border-radius:50%;display:grid;place-items:center;color:#fff;cursor:ew-resize;
  background:rgba(16,19,26,.5);backdrop-filter:blur(9px);border:1.5px solid rgba(255,255,255,.88);
  box-shadow:0 10px 26px -8px rgba(0,0,0,.75)}
.bas-handle:focus-visible{outline:none;box-shadow:0 10px 26px -8px rgba(0,0,0,.75),0 0 0 5px rgba(244,162,60,.55)}

/* subtle "I'm interactive" pulse, stopped after first interaction + under reduced motion */
.bas-handle{animation:bas-pulse 2.6s ease-in-out infinite}
@keyframes bas-pulse{0%,100%{box-shadow:0 10px 26px -8px rgba(0,0,0,.75),0 0 0 0 rgba(244,162,60,0)}
  50%{box-shadow:0 10px 26px -8px rgba(0,0,0,.75),0 0 0 8px rgba(244,162,60,.16)}}
.bas-compare.is-active .bas-handle{animation:none}
@media (prefers-reduced-motion:reduce){.bas-handle{animation:none}}
```

## JavaScript

```js
(function(){
  var compare=document.getElementById('basCompare'),
      handle=document.getElementById('basHandle');
  if(!compare||!handle)return;
  var pos=50, dragging=false, interacted=false,
      reduce=matchMedia('(prefers-reduced-motion: reduce)').matches;

  function clamp(v){return v<0?0:(v>100?100:v);}
  function commit(v){
    pos=clamp(v);
    compare.style.setProperty('--bas-pos',pos+'%');   // moves clip + divider + handle
    var p=Math.round(pos);
    handle.setAttribute('aria-valuenow',p);
    handle.setAttribute('aria-valuetext','Divider at '+p+' percent — original left, graded right.');
  }
  function xToPct(x){var r=compare.getBoundingClientRect();return clamp((x-r.left)/r.width*100);}
  function firstTouch(){if(!interacted){interacted=true;compare.classList.add('is-active');}}

  // pointer: grab anywhere on the image; setPointerCapture keeps tracking off-element
  compare.addEventListener('pointerdown',function(e){
    firstTouch(); dragging=true;
    try{compare.setPointerCapture(e.pointerId);}catch(_){}
    handle.focus({preventScroll:true});
    commit(xToPct(e.clientX)); e.preventDefault();
  });
  compare.addEventListener('pointermove',function(e){if(dragging)commit(xToPct(e.clientX));});
  compare.addEventListener('pointerup',  function(){dragging=false;});
  compare.addEventListener('pointercancel',function(){dragging=false;});

  // keyboard: the handle IS the slider
  handle.addEventListener('keydown',function(e){
    var step=e.shiftKey?10:2, k=e.key, ok=true;
    if(k==='ArrowLeft'||k==='ArrowDown')commit(pos-step);
    else if(k==='ArrowRight'||k==='ArrowUp')commit(pos+step);
    else if(k==='Home')commit(0);
    else if(k==='End')commit(100);
    else ok=false;
    if(ok){firstTouch();e.preventDefault();}
  });

  commit(50);
})();
```

## How it works

- **Two copies, one source.** Both `.bas-after img` and `.bas-before img` point at the *same file* and cover the box with `object-fit:cover`. The "before" look is created entirely in CSS (`filter:` desaturate + sepia + lower contrast), so there is no second asset to ship.
- **`clip-path: inset()` does the reveal.** `inset(0 calc(100% - var(--bas-pos)) 0 0)` clips the *right* edge of the top layer by `100% − pos`, so only the left `pos%` of the "before" remains visible. Crucially, `clip-path` *hides* pixels — it never resizes the `<img>` — so the image can't squash as the divider moves (the classic bug when people animate `width` instead).
- **One variable, three elements.** `--bas-pos` positions the clip, the divider line (`left`) and the handle (`left`) together, so they always stay locked.
- **Pointer + keyboard.** `pointer*` events (with `setPointerCapture`) handle mouse, touch and pen in one path and keep tracking even if the cursor leaves the box. The handle is a `role="slider"` with `tabindex="0"`, so arrow keys / Home / End move it and screen readers announce the value via `aria-valuenow` + `aria-valuetext`.

## Customization

- **The "before" look:** swap the `filter` on `.bas-before img`. Restoration → `sepia(.7) contrast(.8)`; B&W reveal → `grayscale(1)`; blur-to-sharp → `blur(6px) brightness(.95)`; "night mode" → `brightness(.5) saturate(1.4)`.
- **Real two-image compare:** point the two `<img>`s at different files (e.g. a renovation photo pair) and drop the filter entirely — the mechanism is identical.
- **Vertical wipe:** change the clip to `inset(0 0 calc(100% - var(--bas-pos)) 0)`, move the divider/handle with `top` instead of `left`, and read `clientY` in `xToPct`.
- **Keyboard step / handle size / accent:** tune `step`, the handle `width`/`height`, and the amber focus-ring colour.

## Accessibility & performance

- The control is a true `role="slider"`: focusable, arrow-key driven, with a live `aria-valuetext`. The decorative layers/divider are `aria-hidden`; only the graded base image carries real `alt` text.
- A **visible focus ring** (`:focus-visible`) and a one-time idle **pulse** signal that the handle is interactive; the pulse stops on first use and is disabled under `prefers-reduced-motion`.
- `touch-action:none` stops the page scroll-hijacking the drag on touch devices; `draggable="false"` + `-webkit-user-drag:none` stop the browser's native image-drag ghost.
- Cheap to run: `clip-path` and `transform`/`left` are GPU-friendly and only change on input — there is no animation loop. Filters are rasterised once, not per frame.

## Gotchas

- **Don't resize the layer to reveal it** (`width: pos%`) — that squashes the image. Clip it (`clip-path` / `overflow:hidden` on a fixed-size inner) so the picture keeps its geometry.
- **Both images must be the exact same box and `object-fit`,** or the two halves won't line up at the divider.
- **Capture the pointer** (`setPointerCapture`) or a fast drag that leaves the element drops the gesture mid-swipe.
- **Cover the whole control with the pointer target,** not just the thin handle — users expect to grab anywhere on the image.
- `calc(100% - var(--bas-pos))` needs `--bas-pos` to be a *percentage* (or otherwise length-compatible) unit; feeding it a bare number breaks the `calc`.
