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 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.
Before
After
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.
Top
Bottom
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.
Compressed
High Quality
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.
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.)
Compressed
Original
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.
Before
After
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 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.
<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:
<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:
<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". 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-coveron both images, so a16:9"before" paired with a4: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 explicitaspect-ratiorule. - 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 letobject-coverhandle 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:
:root {
--wk-image-compare-divider-size: 3px;
--wk-image-compare-handle-size: 3rem;
}
Or on a specific instance with inline style:
<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() / WireKit::scope():
| 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 |
// 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":
<x-wirekit::image-compare
before="/hero-old.jpg"
after="/hero-new.jpg"
scope="marketing-hero"
/>
See Also
- Customization —
WireKit::scope()andWireKit::personalize()APIs - Design Tokens — the design tokens the image-compare component reads from
- Range Slider — sibling dual-handle slider component for numeric range selection