---
title: Image Compare
description: Before/after image comparison slider
visibility: guest
draft: false
---

# Image Compare

The `<x-wirekit::image-compare>` component ships a before/after image slider for marketing pages, portfolio galleries, product pages (retouching, theme swaps, brand redesigns), and documentation that needs to show "old vs. new" side-by-side. It works out of the box with both horizontal (left/right) and vertical (top/bottom) orientations, supports full keyboard and touch interaction, and is Livewire-first via `wire:model` — no extra setup needed.

The component is fully [WAI-ARIA Slider Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/slider/) compliant: the draggable handle exposes `role="slider"`, `aria-valuenow`, `aria-valuemin`, `aria-valuemax`, and `aria-orientation`, plus a polite live region that announces the current reveal percentage.

## Basic Usage

Drop in two image URLs (the `before` and `after`), and the component handles the rest. The handle starts at the midpoint and can be dragged or operated via keyboard.

:::preview{title="Horizontal compare"}
<x-wirekit::image-compare
    ratio="16/9"
    class="max-w-[40rem]"
    before="/_assets/sample_1920x1080_0p.jpg"
    after="/_assets/sample_1920x1080_98p.jpg"
    before-alt="Same photograph at 0% JPEG quality"
    after-alt="High quality photograph"
    aria-label="Compare high quality vs heavily compressed image"
/>
:::

## Vertical Orientation

Swap the axis with `orientation="vertical"`. The handle now moves top-to-bottom and the keyboard map switches: `ArrowDown` increases `value` (reveals more of the after image), `ArrowUp` decreases it.

:::preview{title="Vertical compare"}
<x-wirekit::image-compare
    orientation="vertical"
    ratio="3/4"
    class="max-w-[28rem]"
    before="/_assets/sample_840x1120_0p.jpg"
    after="/_assets/sample_840x1120_98p.jpg"
    before-alt="Same photograph at 0% JPEG quality"
    after-alt="High quality photograph in portrait"
    before-label="Top"
    after-label="Bottom"
    aria-label="Compare vertical image quality"
/>
:::

## Custom Labels and Initial Value

Both labels default to `"Before"` / `"After"`, but any string is accepted. Use `:value="…"` to set the starting handle position (clamped to `[0, 100]`). Pass `before-label="{{"null"}}"` from PHP to hide a single badge while leaving the other visible.

:::preview{title="Custom labels"}
<x-wirekit::image-compare
    ratio="16/9"
    class="max-w-[40rem]"
    before="/_assets/sample_1920x1080_0p.jpg"
    after="/_assets/sample_1920x1080_98p.jpg"
    before-alt="Same photograph at 0% JPEG quality"
    after-alt="High quality photograph"
    before-label="Compressed"
    after-label="High Quality"
    :value="30"
    aria-label="Compare image quality with custom labels"
/>
:::

## Labels Hidden

Set `:labels="false"` to remove both badges in one shot — useful when the surrounding page already provides context, or when the layout has its own caption treatment.

:::preview{title="No labels"}
<x-wirekit::image-compare
    :labels="false"
    ratio="16/9"
    class="max-w-[40rem]"
    before="/_assets/sample_1920x1080_0p.jpg"
    after="/_assets/sample_1920x1080_98p.jpg"
    before-alt="Same photograph at 0% JPEG quality"
    after-alt="High quality photograph"
    aria-label="Compare image quality without labels"
/>
:::

## Wide Aspect Ratio

Pass any `ratio` — `"21/9"`, `"16/9"`, `"4/3"`, `"1/1"` — and the component sizes itself; both images render with `object-cover`, so they fill the box without stretching. (Omit `ratio` to instead fill the height of a box you size yourself — a fixed-height parent or your own aspect-ratio wrapper.)

:::preview{title="21:9 cinematic"}
<x-wirekit::image-compare
    ratio="21/9"
    class="max-w-[48rem]"
    before="/_assets/sample_1920x823_0p.jpg"
    after="/_assets/sample_1920x823_98p.jpg"
    before-alt="Same crop at 0% JPEG quality"
    after-alt="High quality cinematic crop"
    before-label="Compressed"
    after-label="Original"
    aria-label="Compare cinematic crop quality"
/>
:::

## Decorative Images

For purely decorative comparisons where the images carry no information (e.g. theme-preset demos, gradient showcases), set `decorative="true"`. Both `alt` attributes are forced empty, the outer `<figure>` gets `role="presentation"`, and the interactive slider handle still exposes `role="slider"` so keyboard users can operate it.

:::preview{title="Decorative mode"}
<x-wirekit::image-compare
    :decorative="true"
    ratio="16/9"
    class="max-w-[40rem]"
    before="/_assets/sample_1920x1080_0p.jpg"
    after="/_assets/sample_1920x1080_98p.jpg"
    aria-label="Decorative image quality comparison"
/>
:::

## Props Reference

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `before` | `string` | — (required) | URL of the "before" image. |
| `after` | `string` | — (required) | URL of the "after" image. |
| `beforeAlt` | `string` | `""` | Alt text for the before image. Override for non-decorative use. |
| `afterAlt` | `string` | `""` | Alt text for the after image. Override for non-decorative use. |
| `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | Slider axis. Horizontal reveals left→right; vertical reveals top→bottom. |
| `ratio` | `string \| null` | `null` | Intrinsic aspect-ratio (`"16/9"`, `"4/3"`, `"1/1"`, …). The component has absolutely-positioned images, so it needs a sized box — set `ratio` and it sizes itself (no external `aspect-ratio` wrapper). Omit it to fill the height of a box you size yourself. |
| `value` | `int` | `50` | Initial handle position in percent. Clamped to `[0, 100]`. |
| `beforeLabel` | `string \| null` | `"Before"` | Text for the before badge. Pass `null` to hide just this badge. |
| `afterLabel` | `string \| null` | `"After"` | Text for the after badge. Pass `null` to hide just this badge. |
| `labels` | `bool` | `true` | Master toggle for both badges. `false` hides them regardless of individual label values. |
| `decorative` | `bool` | `false` | When `true`, forces empty alts and adds `role="presentation"` on the figure. |
| `ariaLabel` | `string` | `"Image comparison slider"` | Accessible name for the slider handle. Override for context-specific descriptions. |
| `loading` | `"lazy" \| "eager"` | `"lazy"` | Passed through to both `<img>` tags. |
| `scope` | `string \| null` | `null` | Named [personalization scope](/customization) for block-level class overrides. |

Any additional HTML attributes (`id`, `class`, `data-*`, `style`) merge onto the outermost `<figure>` wrapper — the same as every other WireKit component.

## Livewire Integration

### Standard binding

The component writes the current value back to a Livewire property via `wire:model`. By default the binding is deferred: the server property updates only when the user commits (handle blur or keyboard confirmation), which avoids per-step network round-trips during drags.

```blade
<x-wirekit::image-compare
    before="/images/old.jpg"
    after="/images/new.jpg"
    wire:model="comparePosition"
/>
```

### Live binding

For real-time feedback (e.g. a counter that updates as the user drags), use `wire:model.live`. Combine with `.debounce.200ms` to throttle the network traffic server-side:

```blade
<x-wirekit::image-compare
    before="/images/old.jpg"
    after="/images/new.jpg"
    wire:model.live.debounce.200ms="comparePosition"
/>
```

### Custom slide event

If Livewire is not in play — or if a sibling Alpine component needs to react to slide changes without a server round-trip — listen for the custom `wirekit:image-compare-slide` event. It bubbles up the DOM with `{ value, orientation }` in the detail payload:

```blade
<div
    x-data="{ percent: 50 }"
    x-on:wirekit:image-compare-slide.window="percent = $event.detail.value"
>
    <x-wirekit::image-compare before="/old.jpg" after="/new.jpg" />
    <p>Currently revealed: <span x-text="percent"></span>%</p>
</div>
```

## Accessibility

### WAI-ARIA Attributes

| Attribute | Value | Where |
|-----------|-------|-------|
| `role` | `slider` | Handle button |
| `aria-valuenow` | Current `value` (0-100) | Handle button |
| `aria-valuemin` | `0` | Handle button |
| `aria-valuemax` | `100` | Handle button |
| `aria-orientation` | `"horizontal"` or `"vertical"` | Handle button |
| `aria-label` | Configurable, default `"Image comparison slider"` | Handle button |
| `aria-live` | `polite` | Screen-reader live region |
| `aria-atomic` | `true` | Screen-reader live region |
| `role="presentation"` | Applied only when `decorative="true"` | Outer `<figure>` |

### Screen Reader Announcements

A visually-hidden `aria-live="polite"` region inside the component announces `"{value}% revealed"` after every value change. Polite announcements are batched by the browser/AT, so fast drags will not spam screen readers — only the final committed value is announced.

### Touch Targets

The draggable handle is ≥44×44 CSS pixels, meeting [WCAG 2.5.5 (AAA) "Target Size"](https://www.w3.org/WAI/WCAG22/Understanding/target-size-enhanced.html). If a developer needs a larger thumb, override the `--wk-image-compare-handle-size` CSS variable (see below).

### Reduced Motion

The component respects `prefers-reduced-motion: reduce`. All transitions on the figure and its children collapse to zero, so dragging and keyboard stepping jump instantly from one value to the next without easing — preventing involuntary motion for users with vestibular sensitivities.

### Decorative Mode

When `decorative="true"` the outer `<figure>` becomes `role="presentation"` and both `alt` attributes are forced empty. The interactive `role="slider"` handle remains focusable and keyboard-operable — decorative markup never makes the slider disappear for assistive tech. Use decorative mode only for images that carry no information (theme demos, gradient showcases); anything substantive deserves a real `alt`.

## Keyboard Interaction

The handle is a `<button>` element so it is keyboard-reachable with `Tab`. Once focused, these keys operate the slider:

| Key | Horizontal | Vertical |
|-----|-----------|----------|
| `ArrowLeft` | −1 | — (ignored) |
| `ArrowRight` | +1 | — (ignored) |
| `ArrowUp` | +1 (reveal more of after) | −1 (reveal less of after) |
| `ArrowDown` | −1 (reveal less of after) | +1 (reveal more of after) |
| `PageUp` | +10 | +10 |
| `PageDown` | −10 | −10 |
| `Home` | 0 (show only before) | 0 (show only before) |
| `End` | 100 (show only after) | 100 (show only after) |

Vertical orientation inverts the up/down semantics compared to the WAI-ARIA volume-slider convention: because "revealing more of the after image" runs top→bottom, `ArrowDown` increases `value`. This matches user expectation for image comparison — a sighted user drags downward to reveal more of what is underneath.

## Pitfalls

- **Mismatched aspect ratios.** The component uses `object-cover` on both images, so a `16:9` "before" paired with a `4:3` "after" will have the after image cropped where it overflows. For faithful comparisons, export both frames at the same aspect ratio and wrap the component in a parent with an explicit `aspect-ratio` rule.
- **Large hero images.** `clip-path: inset()` is repainted on every pointermove. On 4K+ imagery and low-end devices this can cause dropped frames during drag. Export hero comparison images at ≤1920×1080 and let `object-cover` handle upscaling on high-density displays.
- **Livewire mid-drag server updates.** If the bound property is updated from the server during a drag (e.g. another tab edits the same Livewire component), the handle jumps to the new position mid-gesture. This is expected Livewire two-way binding behavior. For single-user experiences it is never an issue; for multi-user collaborative views, consider a custom slide-event listener with manual commit logic instead of `wire:model`.
- **No video support.** This component is images-only. There is no `<video>`, `<picture>`-with-source-media, or canvas-frame support — those introduce playback synchronization, reduced-motion conflicts, and bundle-size pressure that would compromise the component's focused role. For video comparisons, use a dedicated video library.
- **Hover-autoslide is not supported.** An earlier draft considered hover-to-reveal but it was rejected on accessibility grounds: hover has no keyboard equivalent, triggers involuntary motion for users with vestibular disorders even with `prefers-reduced-motion` (hover is mechanical, not CSS), and is confusing for pointer-less devices. Users interact with the handle via click/drag/touch or the keyboard — nothing else.

## Design Tokens

| Token | Used for |
| --- | --- |
| `--text-wk-xs` | Label badge font size |
| `--color-wk-text` | Slider handle stroke + label text |
| `--color-wk-text-muted` | Caption + meta text |
| `--color-wk-bg-elevated` | Slider handle background |
| `--color-wk-bg-muted` | Label badge background |
| `--color-wk-border` | Figure outer border |
| `--color-wk-ring` | Slider focus ring |
| `--ring-wk-width` | Focus ring width |
| `--radius-wk-sm` / `--radius-wk-lg` | Label badge + figure border radius |
| `--shadow-wk-sm` / `--shadow-wk-md` | Slider handle drop shadow |

## Customization

### CSS variables

Two component-scoped variables are exposed for visual fine-tuning. Both have hardcoded fallbacks inline, so the component keeps rendering correctly even when a developer ships an older `wirekit.css` than the current template.

| Variable | Default | Purpose |
|----------|---------|---------|
| `--wk-image-compare-divider-size` | `2px` | Thickness of the divider line (height in vertical mode, width in horizontal). |
| `--wk-image-compare-handle-size` | `2.5rem` | Diameter of the draggable thumb. Bump for larger touch targets. |

Override globally in your app CSS:

```css
:root {
    --wk-image-compare-divider-size: 3px;
    --wk-image-compare-handle-size: 3rem;
}
```

Or on a specific instance with inline `style`:

```blade
<x-wirekit::image-compare
    before="/a.jpg"
    after="/b.jpg"
    style="--wk-image-compare-handle-size: 3rem;"
/>
```

### Personalization scopes

Every visual zone is resolvable through [`WireKit::personalize()`](/customization) / [`WireKit::scope()`](/customization):

| Block key | Applies to |
|-----------|-----------|
| `image-compare.base` | Outer `<figure>` wrapper |
| `image-compare.handle` | The draggable thumb button |
| `image-compare.divider` | The divider line separating before/after |
| `image-compare.label` | Both label badges |

```php
// app/Providers/AppServiceProvider.php
use Pushery\WireKit\WireKit;

public function boot(): void
{
    WireKit::scope('marketing-hero', [
        'image-compare' => [
            'classes' => [
                'base' => 'rounded-none border-0 shadow-none',
                'handle' => 'bg-[var(--color-wk-accent)] border-0',
            ],
        ],
    ]);
}
```

Then opt in per instance with `scope="marketing-hero"`:

```blade
<x-wirekit::image-compare
    before="/hero-old.jpg"
    after="/hero-new.jpg"
    scope="marketing-hero"
/>
```

## See Also

- [Customization](/customization) — `WireKit::scope()` and `WireKit::personalize()` APIs
- [Design Tokens](/theming/design-tokens) — the design tokens the image-compare component reads from
- [Range Slider](./range-slider.md) — sibling dual-handle slider component for numeric range selection
