Compare Slider

Two versions of the same frame, split by a divider you drag, or move with the arrow keys.

Loading preview

Installation

npx shadcn@latest add @loomui/compare-slider

Usage

import { CompareSlider } from "@/components/ui/compare-slider"
<CompareSlider
  className="h-64 w-full rounded-xl border"
  before={<img src="/before.jpg" alt="Before" />}
  after={<img src="/after.jpg" alt="After" />}
/>

before sits in flow and gives the frame its size. after is laid over it and clipped to everything past the divider, so both are always rendered at the same dimensions and nothing reflows as the divider moves.

position is where the divider sits, 0 to 100. Left of it is before, right of it is after.

Dragging

The whole frame takes the gesture, so a click anywhere jumps the divider and carries straight on into a drag. Pointer capture keeps the drag alive when the pointer leaves the frame, which is what stops the divider sticking at an edge.

The divider has no transition while it is being dragged. A divider that eases toward the pointer instead of sitting under it feels like it is being towed. Arrow keys do get the transition, because a step with no motion reads as a jump.

touch-action is set to the axis the divider does not travel on, so a finger that lands on the frame can still scroll the page.

Controlled

const [position, setPosition] = React.useState(50)
 
<CompareSlider
  position={position}
  onPositionChange={setPosition}
  before={...}
  after={...}
/>

Leave position out and the slider owns it. defaultPosition sets where it starts.

Vertical

<CompareSlider orientation="vertical" before={...} after={...} />

before is on top, after below, and the handle turns with it. Arrow up and down move the divider, and touch-action swaps to let horizontal panning through instead.

Props

PropTypeDefaultDescription
beforeReactNoderequiredIn flow. Sets the size of the frame.
afterReactNoderequiredLaid over the top, clipped to the divider.
positionnumbernoneDivider position, 0 to 100.
defaultPositionnumber50Starting position when uncontrolled.
onPositionChange(position: number) => voidnoneCalled with the position moved to.
orientation"horizontal" | "vertical""horizontal"Which way the frame splits.
stepnumber2Percent per arrow key. Shift moves five.
labelstring"Compare"Name for the divider.
classNamestringnoneMerged onto the frame. Size goes here.

Any other <div> prop is forwarded.

Accessibility

The divider is a real role="slider" with a live aria-valuenow, reachable by tab and moved with the arrows, Home and End. Give it a label that says what is being compared. The default is only a fallback. Both sides stay in the DOM, so alt text on either image is always available.