Image Compare
Headless before/after image comparison with a WAI-ARIA slider handle.
|
|
| Package |
@vizejs/ui/image-compare |
| Maturity |
stable |
| Own the source |
vize lib pull image-compare |
| Requires |
context, controllable-state, id |
| Aliases |
image compare, before after, comparison slider, image diff, reveal slider |
| Covers |
WAI-ARIA Slider, img-comparison-slider, react-compare-slider, Ark UI Splitter-style comparisons |
Minimal setup
Use a Vue 3.5+ project with Vue SFC compilation configured (see Vite+ integration).
vp install @vizejs/ui
Add the public import below to <script setup lang="ts">. Components provide behavior without styles; the preview uses optional Paper styles.
import { ImageCompareAfter, ImageCompareBefore, ImageCompareHandle, ImageCompareLabel, ImageCompare, ImageCompareRoot } from "@vizejs/ui/image-compare";
Or copy the source into your project with vize lib pull image-compare (see Source Distribution).
Try it
This is the basic example maintained with the component source, using the public package imports. The preview adds the optional Paper styles.
Open the interactive example · Styling these components
Copy the example
<!-- Before/after photo comparison with a keyboard-operable slider handle bound with v-model. -->
<script setup lang="ts">
import { ref } from "vue";
import {
ImageCompare,
ImageCompareAfter,
ImageCompareBefore,
ImageCompareHandle,
ImageCompareLabel,
} from "@vizejs/ui/image-compare";
const position = ref(50);
</script>
<template>
<ImageCompare v-model="position">
<ImageCompareBefore>
<img src="https://example.com/media/kitchen-before.jpg" alt="Kitchen before renovation" />
</ImageCompareBefore>
<ImageCompareAfter>
<img src="https://example.com/media/kitchen-after.jpg" alt="Kitchen after renovation" />
</ImageCompareAfter>
<ImageCompareLabel side="before">Before</ImageCompareLabel>
<ImageCompareLabel side="after">After</ImageCompareLabel>
<ImageCompareHandle aria-label="Reveal renovated kitchen" />
</ImageCompare>
<output>{{ position }}% before</output>
</template>
API
ImageCompareAfter
Source: src/families/media/image-compare/image-compare-after.vue
Slots
| Slot |
Slot props |
Description |
default |
ImageCompareSlotState |
The after image or content. Receives the divider state. |
Exposed
| Member |
Type |
Description |
element |
Readonly<ShallowRef<HTMLElement | null>> |
Template ref to the rendered root element. |
ImageCompareBefore
Source: src/families/media/image-compare/image-compare-before.vue
Slots
| Slot |
Slot props |
Description |
default |
ImageCompareSlotState |
The before image or content. Receives the divider state. |
Exposed
| Member |
Type |
Description |
element |
Readonly<ShallowRef<HTMLElement | null>> |
Template ref to the rendered root element. |
ImageCompareHandle
Source: src/families/media/image-compare/image-compare-handle.vue
Props
| Prop |
Type |
Default |
Description |
ariaLabel |
string |
undefined |
Accessible name. Defaults to messages.handleLabel, then "Comparison position". |
ariaLabelledby |
string |
undefined |
Space-separated ids that label the handle; takes precedence over ariaLabel. |
ariaDescribedby |
string |
undefined |
Space-separated ids that describe the handle. |
Slots
| Slot |
Slot props |
Description |
default |
ImageCompareSlotState |
Handle contents such as a grip icon. Receives the divider state. |
Exposed
| Member |
Type |
Description |
element |
HTMLDivElement | null |
Rendered slider element. |
ImageCompareLabel
Source: src/families/media/image-compare/image-compare-label.vue
Props
| Prop |
Type |
Default |
Description |
side (required) |
ImageCompareSide |
required |
Which image this caption describes. |
Slots
| Slot |
Slot props |
Description |
default |
ImageCompareSlotState |
Caption text such as "Before" or "After". Receives the divider state. |
Exposed
| Member |
Type |
Description |
element |
Readonly<ShallowRef<HTMLElement | null>> |
Template ref to the rendered root element. |
ImageCompare
Source: src/families/media/image-compare/image-compare-root.vue
Props
| Prop |
Type |
Default |
Description |
id |
string | null |
undefined |
Consumer-owned base id. null and undefined select a deterministic fallback. |
modelValue |
number |
undefined |
Controlled divider position in percent (0–100). undefined selects uncontrolled behavior. |
defaultValue |
number |
50 |
Initial uncontrolled divider position in percent. |
orientation |
ImageCompareOrientation |
"horizontal" |
Divider axis. horizontal moves a vertical divider left and right. |
dir |
ImageCompareDirection |
"ltr" |
Reading direction; RTL measures horizontal positions from the right edge. |
step |
number |
1 |
Arrow-key increment and snapping step in percent. 0 disables snapping. |
pageStep |
number |
10 |
PageUp/PageDown increment in percent. |
mode |
ImageCompareMode |
"drag" |
Pointer behavior: press-and-drag, or follow the hovering pointer. |
disabled |
boolean |
false |
Suppress pointer and keyboard changes and remove the handle from the tab order. |
messages |
ImageCompareMessages |
undefined |
Localized handle name and value text. Omitted entries use English defaults. |
Events
| Event |
Payload |
Description |
update:modelValue |
[position: number] |
Fired with the requested position whenever it changes. |
change |
[position: number, previous: number, source: ImageCompareChangeSource] |
Fired after every distinct position request with its source. |
commit |
[position: number] |
Fired when a pointer drag or keyboard interaction finishes. |
Slots
| Slot |
Slot props |
Description |
default |
ImageCompareSlotState |
Before/after images, handle, and labels. Receives the divider state. |
Exposed
| Member |
Type |
Description |
element |
Readonly<ShallowRef<HTMLElement | null>> |
Template ref to the rendered root element. |
focus |
— |
|
orientation |
— |
|
position |
— |
|
setPosition |
— |
|
state |
— |
|
ImageCompareRoot
Source: src/families/media/image-compare/image-compare-root.vue
Props
| Prop |
Type |
Default |
Description |
id |
string | null |
undefined |
Consumer-owned base id. null and undefined select a deterministic fallback. |
modelValue |
number |
undefined |
Controlled divider position in percent (0–100). undefined selects uncontrolled behavior. |
defaultValue |
number |
50 |
Initial uncontrolled divider position in percent. |
orientation |
ImageCompareOrientation |
"horizontal" |
Divider axis. horizontal moves a vertical divider left and right. |
dir |
ImageCompareDirection |
"ltr" |
Reading direction; RTL measures horizontal positions from the right edge. |
step |
number |
1 |
Arrow-key increment and snapping step in percent. 0 disables snapping. |
pageStep |
number |
10 |
PageUp/PageDown increment in percent. |
mode |
ImageCompareMode |
"drag" |
Pointer behavior: press-and-drag, or follow the hovering pointer. |
disabled |
boolean |
false |
Suppress pointer and keyboard changes and remove the handle from the tab order. |
messages |
ImageCompareMessages |
undefined |
Localized handle name and value text. Omitted entries use English defaults. |
Events
| Event |
Payload |
Description |
update:modelValue |
[position: number] |
Fired with the requested position whenever it changes. |
change |
[position: number, previous: number, source: ImageCompareChangeSource] |
Fired after every distinct position request with its source. |
commit |
[position: number] |
Fired when a pointer drag or keyboard interaction finishes. |
Slots
| Slot |
Slot props |
Description |
default |
ImageCompareSlotState |
Before/after images, handle, and labels. Receives the divider state. |
Exposed
| Member |
Type |
Description |
position |
number |
Divider position from 0 (all after) to 100 (all before), in percent. |
orientation |
ImageCompareOrientation |
Divider axis. |
state |
ImageCompareState |
Interaction state. |
element |
HTMLDivElement | null |
Rendered root element. |
setPosition |
(position: number) => boolean |
Request a position (clamped and snapped). Reports whether it changed. |
focus |
(options?: FocusOptions) => void |
Move focus to the handle. |
Behavior
Normative state x input -> outcome table for image-compare-root.vue,
image-compare-before.vue, image-compare-after.vue, image-compare-label.vue,
and image-compare-handle.vue (@vizejs/ui/image-compare). Every row is proven
by the named test.
The root owns the divider position (0–100, percent) and publishes it as
--vize-ui-image-compare-position. No CSS ships; a typical recipe stacks both
images and clips the "before" side:
[data-vize-ui="image-compare-root"] {
position: relative;
}
[data-vize-ui="image-compare-before"],
[data-vize-ui="image-compare-after"] {
position: absolute;
inset: 0;
}
[data-vize-ui="image-compare-before"] {
z-index: 1;
clip-path: inset(0 calc(100% - var(--vize-ui-image-compare-position)) 0 0);
}
[data-vize-ui="image-compare-handle"] {
position: absolute;
inset-block: 0;
left: var(--vize-ui-image-compare-position);
}
The handle follows the WAI-ARIA slider pattern. Arrow keys move the divider in
the pressed direction: Right/Left follow the reading direction and Up/Down move
a vertical divider up and down (its value is measured from the top edge).
| ID |
State |
Input |
Outcome |
Evidence |
| IC1 |
default |
render |
root publishes the position variable and data hooks; the handle is a focusable slider with min/max/now/valuetext and orientation |
renders slider semantics, position custom property, parts, and slot state |
| IC2 |
focused handle |
Arrow / Page / Home / End |
moves by step, pageStep, or to an end; clamps at 0 and 100; other keys pass through; emits change (keyboard) and commit |
keyboard follows the slider pattern with step, page step, Home, and End |
| IC3 |
RTL / vertical |
keys and pointer |
RTL measures from the right edge and mirrors horizontal arrows; vertical measures from the top and maps Up/Down |
right-to-left and vertical dividers map keys and pointers to the visual direction |
| IC4 |
drag mode |
primary pointer down / move / up |
captures the pointer, focuses the handle, tracks only that pointer, marks dragging, and emits commit on release; other buttons ignored |
pointer drags move the divider with capture, focus the handle, and commit on release |
| IC5 |
hover mode |
mouse move / touch |
the divider follows hovering mouse and pen pointers without pressing; touch and presses never drag |
hover mode follows mouse pointers without pressing and ignores touch |
| IC6 |
unmeasured root |
pointer down |
zero-size roots ignore pointers |
unmeasured roots ignore pointers |
| IC7 |
controlled |
key press / prop change |
emits the requested position while the controlled value wins; out-of-range values clamp |
controlled position wins until the parent accepts the request |
| IC8 |
disabled |
keys / pointer / API |
the handle leaves the tab order with aria-disabled; user input is ignored; setPosition() still applies |
disabled roots leave the tab order and ignore keys and pointers |
| IC9 |
messages / names |
render |
handle name and value text come from typed messages; ariaLabel / ariaLabelledby / ariaDescribedby override |
messages and explicit names localize the handle |
| IC10 |
exposed instance |
read / setPosition() / focus() |
exposes position, orientation, state, element; requests snap to step and report whether they changed |
exposes typed state and imperative position and focus controls |
| IC11 |
missing provider |
setup |
compound parts fail closed with the shared context diagnostic |
compound parts require a matching root provider |
| IC12 |
pure helpers |
normalize / pointer / key |
clamping, snapping, pointer mapping per axis and direction, and slider key mapping are deterministic |
image-compare-value.test.ts |
| IC13 |
SSR |
isolated requests |
markup is byte-identical, with the position variable and slider attributes rendered on the server |
renders byte-identical image compare markup across isolated SSR requests |
| IC14 |
SSR / hydration |
hydrate |
server markup hydrates without warnings or node replacement |
hydrates image compare markup without warnings or node replacement |
| IC15 |
types |
compile |
unions, slot state, messages, and exposes are closed and read-only |
image-compare.types.test-d.ts |
Pointer listeners attach on mount, so server markup carries no handlers.