Image Comparison Slider
Stack two images and drive a clip with a range input. Keyboard, reduced motion, and a divider that does not steal the picture.
- machine-coding
- interview
- css
- a11y
Custom pointer math on a before/after widget is wasted time. The browser already ships a 0-100 control with arrows, Home, and End: input type="range". An image comparison slider is two stacked photographs and a clip (or a width percent) driven by that value. The divider is paint. It must not become a second hit target that covers the interesting pixel.
The usual CSS bug is shrinking the top <img> to width: 40% and letting object-fit rescales it, so the left edge of “before” is not the left edge of “after.” Both images stay the full box. You hide part of the top one.
Key takeaways
- Native
input type="range"from 0 to 100 is the interaction, including keyboard.- Both images are full-size in one relative box. Clip or overflow hides, it does not scale.
clip-path: inset(0 X% 0 0)or a full-width img inside a clipped wrapper. Same alignment either way.- The vertical divider is
pointer-events: noneso it does not steal the picture.prefers-reduced-motion: reducekills any transition on the clip. The value still updates.
A range input over two stacked images, keyboard, and reduced motion
Layout: one position: relative frame. After (or “right” image) is the full background. Before sits on top, same object-fit and object-position, clipped from the right.
.compare {
position: relative;
overflow: hidden;
aspect-ratio: 16 / 9;
}
.compare img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
display: block;
}
.compare .divider {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
margin-left: -1px;
background: white;
pointer-events: none;
}
.compare input[type="range"] {
position: absolute;
left: 0;
right: 0;
bottom: 0.5rem;
width: 100%;
margin: 0;
z-index: 1;
}
@media (prefers-reduced-motion: reduce) {
.compare img {
transition: none;
}
}
The range sits on the bottom of the frame, not as a fat vertical thumb over the faces in the photo. If you style a tall thumb, keep it 2-4 px and put the actual pointing device on the native input. A div handle with a homemade drag is how people “steal the picture” and then have to reimplement keyboard that range already has.
Clip from state:
function clipFromPercent(percent: number) {
const p = Math.min(100, Math.max(0, percent));
return `inset(0 ${100 - p}% 0 0)`;
}
inset(top right bottom left): a percent of 70 means the top image keeps the left 70% and the right 30% is clipped away. The after image shows through. Width-percent wrappers work if the inner img is still the container width:
.beforeWrap {
position: absolute;
inset: 0 auto 0 0;
overflow: hidden;
}
.beforeWrap img {
width: var(--frame-width);
max-width: none;
}
That wrapper needs the frame’s pixel width (or both images stay position: absolute; width: 100% of the frame and you only clip). clip-path is less layout math. CSS masks and clip-path is the longer reference. mask-image is overkill here.
Do not transition: clip-path 200ms without a reduced-motion query. A comparison slider is a direct manipulation control. Lag feels like a broken range. If you add a short transition for mouse users, cut it under prefers-reduced-motion.
object-fit: cover on both, same object-position, same aspect-ratio on the frame, or the two photos drift. See object-fit and aspect-ratio.
Problem statement
Stack two images. A range input from 0 to 100 reveals one over the other. Keyboard works. Reduced motion is honored. Alt text describes each photograph. A divider line does not intercept pointer events on the image.
Requirements
Must have
- Two images, stacked, aligned
input type="range"min 0 max 100- Clip or width percent bound to the value
- Visible divider that does not capture clicks
- Meaningful
alton both images - Label for the range
Should have
prefers-reduced-motiondisables clip transitions- Controlled
value/onChange - Same
object-fiton both sources
Nice to have
- Vertical comparison (clip from the bottom)
- Swap before/after
- Keyboard-only demo with no custom pointer code
Planning (5 minutes out loud)
- Native range, not a pointer-capture handle, unless they forbid form controls
- Stack + clip-path, both imgs full box
- MVP: slider moves the clip
- Then label, alts, reduced motion
- Divider as CSS,
pointer-events: none
If they demand drag-on-the-image as well, still keep the range for keyboard and wire onPointerDown on the frame to set the percent from clientX. Pointer capture belongs to resizable split panes, not to a second unlabeled thumb.
Architecture
ImageCompare
├── Frame
│ ├── img after
│ ├── img before (clipped)
│ └── divider (paint)
└── range (labelled)
API
type ImageCompareProps = {
beforeSrc: string;
afterSrc: string;
beforeAlt: string;
afterAlt: string;
value?: number;
defaultValue?: number;
onChange?: (percent: number) => void;
};
Alts are required props. Defaulting to "" makes both decorative, which is wrong for a comparison. Images, alt, and decorative: empty alt and missing alt are different. Here both images carry meaning.
Implementation sketch
function ImageCompare({
beforeSrc,
afterSrc,
beforeAlt,
afterAlt,
defaultValue = 50,
onChange,
}: ImageCompareProps) {
const [percent, setPercent] = useState(defaultValue);
const id = useId();
function commit(next: number) {
const p = Math.min(100, Math.max(0, next));
setPercent(p);
onChange?.(p);
}
return (
<figure>
<div className="compare">
<img src={afterSrc} alt={afterAlt} />
<img
src={beforeSrc}
alt={beforeAlt}
style={{ clipPath: clipFromPercent(percent) }}
/>
<div
className="divider"
style={{ left: `${percent}%` }}
aria-hidden="true"
/>
<label htmlFor={id} className="sr-only">
Comparison position
</label>
<input
id={id}
type="range"
min={0}
max={100}
value={percent}
aria-valuetext={`${percent} percent before`}
onChange={(e) => commit(Number(e.target.value))}
/>
</div>
</figure>
);
}
aria-valuetext stops some AT from only saying a raw number with no unit. The visible label can be “Before / after” next to the control if you have room. sr-only plus a visible figcaption is also fine. Do not leave the range unnamed.
Native range already maps:
| Key | Typical result |
|---|---|
| Arrow | ±1 (or ±step) |
| Page Up/Down | larger step |
| Home / End | min / max |
You get that for free. A homemade thumb with div + pointermove and no tabIndex fails the keyboard requirement. Lightbox gallery is the other image widget in this cluster: dialog plus index, not a clip.
useId for the label association. Clicking the label focuses the range.
Accessibility essentials
| Piece | Approach |
|---|---|
| Photos | Real alt on both, different strings |
| Control | Labelled input type="range" |
| Value | aria-valuetext with percent and which side |
| Divider | aria-hidden="true", pointer-events: none |
| Motion | No clip transition under reduced motion |
| Contrast | Divider visible on both light and dark photos (border or mix) |
Do not convey before vs after by color of the handle alone. The label and the alts do that work.
If the product wants the range visually hidden and dragging on the image, you still need a focusable range (or an equivalent slider pattern). Hiding the input with display: none removes it from the keyboard. Visually hide, do not disable.
Interview scoring
| Signal | What they want |
|---|---|
| Control | Native range, 0-100 |
| Alignment | Full-size stack, clip not rescale |
| Keyboard | Works without extra keydown code |
| Divider | Does not eat pointer events |
| A11y | Alts + label |
| Motion | Reduced-motion branch |
They may ask you to drag on the photo. Add it after the range works. Do not start with mousedown math.
Footguns
- Top image
width: percentsoobject-fit: covershows a different crop - Homemade handle covering the subject, no range in the tab order
- One
altfor both, oralt="image" transitiononclip-pathwith no reduced-motion kill switch- Different
object-positionon the two files pointer-events: nonemissing on the divider, so clicks near the line do nothing useful
clip-path on the img does not change layout. Overflow hidden on a wrapper does. Mixing both without a full-size inner image is the rescale bug.
Interview out-loud answer
I would stack the after image and the before image in one relative frame, both absolutely positioned to the same box with the same object-fit. A labelled range input from 0 to 100 sets
clip-path: inset(0 calc(100% - p) 0 0)on the top image. The vertical divider is a 2px line at that percent with pointer-events none, so it never steals the picture. Keyboard comes from the native range. Under prefers-reduced-motion I drop any clip transition. Each photo keeps its own alt text.
Related on this site
- Lightbox Image Gallery
- Resizable Split Panes
- CSS Masks and Clip Path
- prefers-reduced-motion
- Images alt and Decorative