Skip to content
ShineUI
Install

ImageCompare

Before meets after.

<sui-image-compare>MediaStable

About

Before and after comparison with a draggable divider, for scan cleanup previews. The before layer is clipped to ratio of the width; the orange handle drags with the pointer and is a keyboard slider. Pass two sources, or one source with beforeFilter to fake the before from the after.

Usage

<sui-image-compare before-src="/docs/assets/imagery/nonprofits.webp" after-src="/docs/assets/imagery/nonprofits.webp" before-filter="grayscale(1) contrast(0.75) brightness(1.05)" before-label="Original" after-label="Enhanced"></sui-image-compare> <script type="module"> import { defineCustomElements } from "@shineui/core/loader"; import "@shineui/core/shineui.css"; defineCustomElements(); const el = document.querySelector("sui-image-compare"); el.addEventListener("suiError", (e) => console.log(e.detail)); </script>
import { SuiImageCompare } from "@shineui/react"; import "@shineui/core/shineui.css"; export function Example() { return <SuiImageCompare beforeSrc="/docs/assets/imagery/nonprofits.webp" afterSrc="/docs/assets/imagery/nonprofits.webp" beforeFilter="grayscale(1) contrast(0.75) brightness(1.05)" beforeLabel="Original" afterLabel="Enhanced" onSuiError={(e) => console.log(e.detail)} />; }
<script setup> import { SuiImageCompare } from "@shineui/vue"; import "@shineui/core/shineui.css"; const onChange = (e) => console.log(e.detail); </script> <template> <SuiImageCompare before-src="/docs/assets/imagery/nonprofits.webp" after-src="/docs/assets/imagery/nonprofits.webp" before-filter="grayscale(1) contrast(0.75) brightness(1.05)" before-label="Original" after-label="Enhanced" @sui-error="onChange" /> </template>
import { Component } from "@angular/core"; import { SuiImageCompare } from "@shineui/angular"; @Component({ standalone: true, imports: [SuiImageCompare], template: `<sui-image-compare before-src="/docs/assets/imagery/nonprofits.webp" after-src="/docs/assets/imagery/nonprofits.webp" before-filter="grayscale(1) contrast(0.75) brightness(1.05)" before-label="Original" after-label="Enhanced" (suiError)="onChange($event)"></sui-image-compare>`, }) export class Example { onChange(e: CustomEvent) { console.log(e.detail); } }

Basic

One source plus before-filter fakes the before from the after, the common shape for an enhancement preview. The frame fills its container; height sets the rest.

<div style="max-width:520px"><sui-image-compare before-src="/docs/assets/imagery/nonprofits.webp" after-src="/docs/assets/imagery/nonprofits.webp" before-filter="grayscale(1) contrast(0.75) brightness(1.05)" before-label="Original" after-label="Enhanced" height="200"></sui-image-compare></div>

Two sources

Both pictures are decorative by default. Set before-alt and after-alt when the comparison is the content rather than an illustration beside prose.

<div style="max-width:520px"><sui-image-compare id="cmp-two" before-src="/docs/assets/imagery/construction.webp" after-src="/docs/assets/imagery/real-estate.webp" before-label="Site" after-label="Finished" height="200"></sui-image-compare></div>
// setup(root): properties and events setup(root) { const el = root.querySelector('#cmp-two'); el.beforeAlt = 'A hard hat resting on rolled plans at a building site'; el.afterAlt = 'An aerial view of a finished suburban street lined with houses and lawns'; }

Controlled ratio and events

ratio is mutable and clamped to 0.04..0.96. suiResize fires while the divider moves and suiResizeEnd when it lands, the pair sui-splitter uses for its ratio; wire expensive work to suiResizeEnd. Neither bubbles.

35% before
<div style="max-width:520px;display:flex;flex-direction:column;gap:10px"> <sui-image-compare id="cmp-controlled" before-src="/docs/assets/imagery/nonprofits.webp" after-src="/docs/assets/imagery/nonprofits.webp" before-filter="grayscale(1) contrast(0.75)" height="200" ratio="0.35"></sui-image-compare> <div style="display:flex;gap:10px;align-items:center"> <sui-button variant="outline" size="sm" id="cmp-left" label="Show before"></sui-button> <sui-button variant="outline" size="sm" id="cmp-mid" label="Halfway"></sui-button> <sui-button variant="outline" size="sm" id="cmp-right" label="Show after"></sui-button> <span id="cmp-readout" style="font:460 13px/1.6 var(--font-sans);color:var(--ink-muted);font-feature-settings:'tnum'">35% before</span> </div> </div>
// setup(root): properties and events setup(root) { const el = root.querySelector('#cmp-controlled'); const out = root.querySelector('#cmp-readout'); const show = (v) => (out.textContent = `${Math.round(v * 100)}% before`); root.querySelector('#cmp-left').addEventListener('click', () => (el.ratio = 0.96)); root.querySelector('#cmp-mid').addEventListener('click', () => (el.ratio = 0.5)); root.querySelector('#cmp-right').addEventListener('click', () => (el.ratio = 0.04)); el.addEventListener('suiResize', (e) => show(e.detail)); el.addEventListener('suiResizeEnd', (e) => show(e.detail)); }

A picture that fails

With either picture lost there is nothing to compare, so the frame shows the image glyph and a quiet line on its well and drops the divider and the labels. suiError fires once per failed source with that source.

Loading.
<div style="max-width:520px;display:flex;flex-direction:column;gap:10px"> <sui-image-compare id="cmp-fail" before-src="/docs/assets/imagery/not-uploaded-receipt.jpg" after-src="/docs/assets/imagery/nonprofits.webp" before-label="Scanned" after-label="Cleaned up" height="160"></sui-image-compare> <span id="cmp-fail-log" style="font:460 13px/1.6 var(--font-sans);color:var(--ink-muted)">Loading.</span> </div>
// setup(root): properties and events setup(root) { const log = root.querySelector('#cmp-fail-log'); root.querySelector('#cmp-fail').addEventListener('suiError', (e) => { log.textContent = `suiError: ${e.detail}`; }); }

Keyboard

KeyAction
TabMoves focus to the handle, a slider whose value is the before share of the width. A pointer press anywhere on the frame moves the divider there and focuses the handle.
ArrowRight ArrowUpMove the divider right by 2%, showing more of the before layer.
ArrowLeft ArrowDownMove the divider left by 2%.
PageUp PageDownMove the divider right or left by 10%.
Home EndJump the divider to the far left or the far right; travel stops at 4% and 96% so the handle stays on the frame.

API

<sui-image-compare>

Properties

PropertyAttributeTypeDefaultDescription
afterAltafter-altstring''Alt text for the after layer. Empty (the default) leaves it decorative.
afterLabelafter-labelstringstring | undefinedUppercase tag at the top right.
afterSrcafter-srcstringstring | undefinedSource of the after layer (right, full frame). Defaults to beforeSrc.
beforeAltbefore-altstring''Alt text for the before layer. Empty (the default) leaves it decorative; set it, with afterAlt, when the comparison itself is the content rather than an illustration beside prose.
beforeFilterbefore-filterstringstring | undefinedCSS filter applied to the before layer, e.g. grayscale(1) contrast(0.7).
beforeLabelbefore-labelstringstring | undefinedUppercase tag at the top left.
beforeSrc*before-srcstringSource of the before layer (left).
heightheightnumber240Frame height in px.
ratiorationumber0.5Divider position, 0..1 of the width. Mutable; clamped to 0.04..0.96 so the handle stays on the frame, wherever the value came from. A press anywhere on the frame moves the divider there and starts a drag, as one range input over the picture would; the handle itself drags from where it is and is the keyboard slider.

Events

EventDetailReactVueDescription
suiErrorstringonSuiError@sui-errorFires when the before or the after picture fails to load, once per source; detail is the source. With either lost there is nothing to compare, so the frame shows the image glyph and a quiet line and drops its divider and labels. Does not bubble.
suiResizenumberonSuiResize@sui-resizeFires while the divider moves (every drag step and every key press); detail is the new ratio (0.04..0.96). The pair is sui-splitter's for the same ratio: a state report, not a field's value. Does not bubble.
suiResizeEndnumberonSuiResizeEnd@sui-resize-endFires when a move commits: on pointer release, or straight away on a key press. Detail is the ratio (0.04..0.96). Do the expensive work here. Does not bubble.

Methods

MethodSignatureDescription
setFocus()setFocus() => Promise<void>Moves focus to the divider, the keyboard slider. A frame whose pictures failed has no divider and keeps focus where it is. The host's own focus() does the same.

Theming

Every design token this element can render with, read from its own stylesheets and the ones it imports. Set a token on :root or on any ancestor and every use below follows it. The values shown are the ones in the theme you are viewing. The list is read per stylesheet, so an element that imports a shared drawing module lists that module's tokens too, whether or not it paints with all of them.

See also