Skip to main content
WireKit
Copy for LLM

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.

Horizontal compare
Same photograph at 0% JPEG quality High quality photograph 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.

Vertical compare
Same photograph at 0% JPEG quality High quality photograph in portrait 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.

Custom labels
Same photograph at 0% JPEG quality High quality photograph 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.

No labels
Same photograph at 0% JPEG quality High quality photograph

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.)

21:9 cinematic
Same crop at 0% JPEG quality High quality cinematic crop 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.

Decorative mode

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-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:

: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

  • CustomizationWireKit::scope() and WireKit::personalize() APIs
  • Design Tokens — the design tokens the image-compare component reads from
  • Range Slider — sibling dual-handle slider component for numeric range selection

Was this page helpful?

Voting requires cookies or local storage. What we store