Composites
Compare Slider
Two images stacked in one frame, with a divider that reveals one or the other — the screen where someone checks an edited picture against its original before it ships, or where a client looks over the result. Horizontal or vertical, driven by pointer, touch or keyboard, and visible on any image.
- Import
import { CompareSlider, CompareSliderBefore, CompareSliderAfter, CompareSliderHandle, CompareSliderLabel } from '@comitor/ui'— the main entry. No optional peer to install.- Runtime
- A client component. The parts throw outside a
CompareSlider, andlabels.valueTextis a function, so an override has to be written in a client component. - Origin
- New in
3.15.0, ported from Dice UI (MIT, © 2024 Sadman Sakib). It was left out of the2.1.0ports as a marketing effect; it came back as a working tool for reviewing edited images. What changed on the way is listed under What the port guarantees; the release notes are in the changelog.
Anatomy
CompareSlider is the frame. CompareSliderBefore and CompareSliderAfter are two layers that both fill it (absolute inset-0) and are clipped at the divider — Before on the left, or on top when vertical. CompareSliderHandle draws the divider and is the slider itself; CompareSliderLabel is an optional corner chip. The value is a percentage: how much of the Before side is showing.
import {
CompareSlider,
CompareSliderAfter,
CompareSliderBefore,
CompareSliderHandle,
CompareSliderLabel
} from '@comitor/ui'
<CompareSlider // the frame — has NO height of its own: give it aspect-* or h-*
defaultValue={50} // or value + onValueChange — % of the BEFORE side showing
className="aspect-[3/2] w-full"
>
<CompareSliderBefore asChild> // left (top when vertical)
<img src={original} alt="…" />
</CompareSliderBefore>
<CompareSliderAfter asChild> // right (bottom when vertical)
<img src={edited} alt="…" />
</CompareSliderAfter>
<CompareSliderHandle /> // role="slider" — the ONLY keyboard stop. Never leave it out.
<CompareSliderLabel side="before" /> // "Before" — default text from the compareSlider namespace
<CompareSliderLabel side="after" /> // "After"
</CompareSlider>Because both layers are absolutely positioned, the frame has no height of its own — without one it collapses to 0 and nothing shows. Give it an aspect-* class that matches the images, or a fixed h-*; either way the page does not jump when the images finish loading.
Basic usage
The usual shape: asChild on both layers, so each <img> is the layer — it receives object-cover, the full size and the clip. Drag the divider, or click anywhere on the picture: the divider jumps there, and focus moves to the handle so the arrow keys work straight after. On a phone, a vertical swipe across the picture still scrolls the page.
An <img> cannot hold children, so a layer rendered asChild ignores its label prop — in development the console says so. Put a separate <CompareSliderLabel side="before" /> and side="after" inside the frame instead; without children they show the default text for their side.
Labels on the layers
Without asChild a layer is a <div> that can carry its own label. label takes true for the default text of the language in use — “Before” and “After” in English — or a string of your own, here the revision being reviewed. Size the image inside yourself (size-full object-cover).
A labelled layer becomes role="group" named by its label, and the alt of the image inside is still read. The label is a solid chip — background and text from the theme, no transparency — so it reads the same over any picture.
Controlled position
value and onValueChange hold the position in your state — for presets, for keeping two comparisons in step, or for remembering where a reviewer left off. 0 shows only the edited image, 100 only the original. Use the buttons, then drag: the counter only moves for changes made in the slider — setting value from outside does not echo back through onValueChange.
value = 50.000 · onValueChange calls = 0
Pointer positions arrive unrounded — 37.2384… — so round before you store or display one. The handle announces it rounded to one decimal.
Keyboard and step
The handle is a single Tab stop. Horizontally the keys behave like a native <input type="range">: → ↑ PageUp increase the value (the divider moves right, showing more of the original), ← ↓ PageDown decrease it, Home goes to 0 and End to 100. step sets one arrow press; Shift + arrow, PageUp and PageDown move ten steps. Tab to the handle below (or click the picture) and try them — each arrow moves 5, with Shift 50.
step = 5 · value = 50.0
Vertically the keys follow the arrow, not the number: ↑ ← PageUp move the divider up, ↓ → PageDown move it down. Since the value is measured from the top edge, ↑ decreases it there — the opposite of the ARIA slider pattern, where up always increases. The announced value text is the real number, so nothing is misreported.
Follow the pointer
interaction="hover" moves the divider with the pointer, no press needed — quick for scanning a picture with a mouse. The knob is hidden in this mode and appears only when the handle has keyboard focus, so a keyboard user still sees where they are. Touchscreens have no hover: there the divider only moves while a finger slides across the picture, so keep the default "drag" where people mostly use phones or tablets.
Vertical
orientation="vertical" puts Before on top and After below — the natural split for a sky or a horizon. The After label moves to the bottom-left corner. One sizing difference: the vertical root is h-full instead of w-full, so set the width yourself and add h-auto to let an aspect-* class size it (or use a fixed h-*). Here a sideways swipe on a phone scrolls; a vertical one moves the divider.
value = 50.0 (share of the top, Before, showing)
Restyling the handle
CompareSliderHandle is the only part that takes keyboard focus and the only one with role="slider". Leave it out and the comparison cannot be operated by keyboard or reached by a screen reader at all. To make it look different, pass children — the element keeps its role, name, value and keys, and gets a focus outline around the strip you draw.
Your drawing replaces the default’s visibility guarantee too. This one keeps it by reusing the same bg-paper + ring-ink pair; a single colour — bg-background, say — vanishes on some images.
A divider you can see on any image
Behind the divider is a photo, not the page, so no single colour is enough: white vanishes on a product cut out on white, near-black on a night shot. The divider is two-toned — a 2px paper core edged with ink — and so is the knob. Both tokens stay the same in every theme and contrast setting. Against any pixel at least one of the two reaches 4.21:1; the worst case is a mid grey like the middle stripe below.
The focus ring follows the same idea: the brand-coloured outline sits between the paper knob and a 4px ink ring, so it shows even on an image in the brand’s own colour.
What the port guarantees
Behaviour you can rely on that the Dice UI original did not give you — each point checked against the upstream source.
- Before is on the left (or top). Upstream’s own documentation says so, but its code clipped Before to the right — which also cut off its own top-left “Before” label on first render. Moving the divider right (a higher value) now shows more of the original.
- The handle is the slider. Upstream put
role="slider"and the tab stop on the whole frame. ARIA treats a slider’s children as presentational, so the labels and every imagealtdisappeared from the accessibility tree; the slider had no name; and the focus ring circled the entire picture. The handle now carries the role, a name, anaria-valuenowrounded to one decimal (upstream: the raw float), a localizedaria-valuetextandaria-orientation. - Keys match the platform. Upstream’s horizontal table had ↓ moving the divider right and PageUp moving it left — the reverse of a native range input. Horizontal now matches
<input type="range">; vertical follows the arrow. - A click jumps the divider. Upstream’s docs promise “click anywhere to jump”; its code only moved on pointer move. Only the primary button counts — a right-click still opens the image’s context menu, so a reviewer can open the full-size file in a new tab.
- Dragging never drags the image. No text selection, no ghost image following the pointer, no native drag cutting the gesture short — without
pointer-events: noneon the images. - The page still scrolls on phones. Upstream set
touch-noneon the frame, locking scroll over what is often a full-width picture. The port usestouch-pan-y(horizontal) ortouch-pan-x(vertical); on touch the divider moves on the first move along its own axis, not on touch-down, so a scroll gesture does not make it jump. Passtouch-noneto lock scrolling. - Visible on any image. Upstream drew the divider and knob in
bg-background, white in light mode and near-black in dark, so it disappeared on white product shots in one theme and on dark photos in the other. Now two-toned, with a two-tone focus ring; the knob appears on keyboard focus in hover mode (upstream drew no knob there, so focus had no indicator). - Labels are named and solid. A labelled layer is
role="group"named by its label, keeping the image’salt(upstream usedrole="img", which overrode it, and hid unlabelled layers). Labels sit on a solid background instead of 80% translucent, andlabelacceptstruefor the default text. - Your handlers run, and drags end. Upstream called your
onPointerDown/onPointerUponly in drag mode and youronPointerMoveonly mid-drag, and apreventDefault()inonPointerUpleft the divider stuck to the pointer. Now yours always run, and a drag ends on pointer up, cancel or lost capture regardless. - Controlled does not echo. Setting
valuefrom the parent no longer callsonValueChangeback with the value just set, and a frame with no size yet (hidden, not laid out) no longer producesNaN.
Language
Four strings live in the compareSlider namespace: the handle’s name, its value text, and the default Before and After labels. valueText is a function because the number sits inside it — Vietnamese writes a decimal comma, “37,5%”. The labels prop beats the nearest ComitorLocaleProvider, which beats English. Switch the language and read the handle back:
Handle in the DOM: aria-label “” · aria-valuetext “”
Image alt text is always yours, in the language of the page. Where the provider goes and how messages overrides work: Get started → Set the language. An app that builds a full message table of its own (a third language) must add the compareSlider namespace from 3.15.0, or typecheck fails.
Props
CompareSlider — every other prop goes to the frame’s <div>.
| Prop | Type | Default | Description |
|---|---|---|---|
value | number | — | Controlled position of the divider, 0–100: the share of the Before side showing, measured from the left edge (top when vertical). Clamped to 0–100. Pair it with onValueChange. |
defaultValue | number | 50 | Uncontrolled starting position. |
onValueChange | (value: number) => void | — | Called on every move — pointer, click, keyboard. Pointer values are unrounded floats. Not called when the parent changes value itself. |
step | number | 1 | Arrow-key increment, in percent. Shift + arrow, PageUp and PageDown move ten steps. |
interaction | 'drag' | 'hover' | 'drag' | drag: press and drag anywhere on the frame, and a click jumps the divider to the pointer. hover: the divider follows the pointer without a press, and the knob only shows on keyboard focus. |
orientation | 'horizontal' | 'vertical' | 'horizontal' | horizontal: Before on the left, the root is w-full and touch-pan-y. vertical: Before on top, the root is h-full and touch-pan-x — set its width, and h-auto if an aspect-* class should size it. |
labels | Partial<CompareSliderLabels> | compareSlider namespace | Overrides single strings of the compareSlider namespace for this instance. Beats the nearest ComitorLocaleProvider. valueText is a function, so it has to be passed from a client component. |
asChild | boolean | false | Renders your element as the frame instead of a div. |
className | string | — | Merged after relative isolate overflow-hidden select-none. This is where the height goes: aspect-[3/2], h-96. Add touch-none to stop page scrolling over the frame entirely. |
onPointerDown / onPointerMove / onPointerUp … | PointerEventHandler | — | Your handlers always run, before the component's own. Calling preventDefault() in onPointerDown or onPointerMove skips the slider's handling of that event; ending a drag never depends on it. |
CompareSliderBefore and CompareSliderAfter — the same props; every other prop goes to the layer.
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | boolean | — | A label drawn in this layer's corner. true = the default text (Before / After, from the provider). A labelled layer becomes role=group named by the label. Ignored with asChild — use a separate CompareSliderLabel. |
asChild | boolean | false | Your element (usually an <img>) becomes the layer: it gets absolute inset-0 size-full object-cover and the clip-path. It cannot hold a label, so label is dropped with a console warning in development. |
className / style | string / CSSProperties | — | Merged onto the layer. style is spread after the computed clip-path, so a clipPath of your own replaces it. |
CompareSliderHandle — every other prop goes to the slider element.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | two-tone divider + knob | Replaces the default drawing. The element stays role=slider and the keyboard stop; with children it gets a 2px focus outline in the ring colour around the handle strip. Your drawing must stay visible on any image — the default's two-tone paper/ink pair does. |
aria-label / aria-labelledby | string | labels.label | The slider's name. Defaults to “Comparison position”. A prop passed here wins over the namespace. |
onKeyDown | KeyboardEventHandler | — | Runs before the built-in keys. Call preventDefault() to take a key over. |
asChild | boolean | false | Renders your element as the handle. It receives role=slider and tabIndex=0. |
CompareSliderLabel — every other prop goes to the chip’s <div>.
| Prop | Type | Default | Description |
|---|---|---|---|
side | 'before' | 'after' | — | Places the label (before: top-left; after: top-right, or bottom-left when vertical) and picks the default text when it has no children. |
children | ReactNode | labels.before / labels.after | Your text. Without it the label shows the default for its side. |
className | string | — | Merged onto the label — a solid background chip, so the text never depends on the image behind it. |
CompareSliderLabels — the compareSlider namespace, and the shape of the labels prop.
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | 'Comparison position' | aria-label of the handle. Vietnamese: “Vị trí so sánh”. |
valueText | (percent: number) => string | (p) => `${p}%` | aria-valuetext of the handle. percent is rounded to one decimal. Vietnamese writes a decimal comma: “37,5%”. |
before | string | 'Before' | Default text of the Before label. Vietnamese: “Trước”. |
after | string | 'After' | Default text of the After label. Vietnamese: “Sau”. |
Known limitations
- No handle, no keyboard. Everything keyboard and screen reader users get lives on
CompareSliderHandle. A comparison without it works only with a pointer. - No height of its own. Forget the
aspect-*orh-*and the frame is 0px tall. In vertical orientation the root is alsoh-fullrather thanw-full. - Vertical ↑ decreases the value. The divider follows the arrow, but the ARIA slider pattern says up increases. The value text is the true number.
- No right-to-left mirroring. Before stays on the left and the arrow keys are not flipped under
dir="rtl". asChilddropslabel. Use a separateCompareSliderLabel; development builds warn in the console.- Controlled means echoed, not enforced. The slider moves before it calls
onValueChange; a parent that ignores the call does not move it back. Passing a newvaluedoes. - Not yet hand-tested everywhere. The package’s release notes list detailed drag and keyboard behaviour, screen readers, Safari and Firefox as not yet checked by hand for this component.
Accessibility
CompareSliderHandleisrole="slider"withtabIndex=0,aria-label(“Comparison position” by default),aria-valuemin0,aria-valuemax100,aria-valuenowrounded to one decimal, a localizedaria-valuetext, andaria-orientation. Name it after the task when a page has several — “Compare retouch of photo 3”.- The frame is a plain
<div>: images keep theiralt, and labelled layers are named groups. Write analtfor both images that says what differs. - Keyboard: one Tab stop. Arrows move by
step, Shift + arrow and PageUp/PageDown by ten steps, Home/End to 0 and 100. Horizontal matches a native range input; vertical follows the arrow. - A pointer press moves focus to the handle, like a native range input, so the keys work straight after a click.
- The default knob is 44px (
size-11), and the handle strip is 40px wide across the full height of the frame — above the 24px target size of WCAG 2.5.8. - The divider, knob and focus ring are two-toned so they stay visible on any image (at least 4.21:1 against one of the two tones); labels are solid chips in theme colours.
Related
- Cropper — the other image tool; its crop frame uses the same paper and ink pair.
- Upload — getting the original and the edited file into the app.
- Slider — a plain range input, for values that are not a comparison.
- Aspect Ratio — another way to give the frame its height.