Playground
Two pictures of the same size, stacked, with a divider the reader drags to reveal one or the other: a raw and a processed telescope frame, a redesign, two profiler traces of the same page load. Drag anywhere on the picture, tap or click to jump, or focus the divider and use the arrow keys. With expandable, a button opens the comparison full screen, among the stars.
The sides are slotted, so the page renders them: <img> with srcset, <picture>, lazy loading and alt text stay yours, and any element works, not only images. The after side sets the size; the before side is cut to it. A <picture>'s inner <img> sits in the page's DOM, so the page sizes it (sb-image-compare img { display: block; inline-size: 100% }). Slotted content doesn't take pointer events: the whole picture is the control.
Examples
Raw and processed
<sb-image-compare before-label="Raw" after-label="Processed" position="35" style="inline-size: min(100%, 30rem)">
<img slot="before" src="/art/hero.svg" alt="The launch frame as the sensor recorded it, grey and flat" width="360" height="168" style="image-rendering: pixelated; filter: grayscale(1) contrast(1.4) brightness(0.8)">
<img slot="after" src="/art/hero.svg" alt="The same frame in colour: a rocket leaving a violet planet, Earth and the moon behind" width="360" height="168" style="image-rendering: pixelated">
</sb-image-compare>
Without a label, the divider's value text says "Before" and "After"; with labels it names them, e.g. "Raw 35%, Processed 65%". Each tag sits on its own side, so the divider cuts it as it cuts the picture.
Full screen
expandable adds a button in the corner. Full screen is a popover over a starfield: the same slotted images move to the top layer, scaled to fit the viewport, and the divider keeps its position. Escape or the button closes it; Tab stays between the divider and the button meanwhile. sb-expand reports both, with a reason (reader, server or api), so a page that stores the state doesn't post the server's own changes back; :state(expanded) styles the open element from the page. The page or the server can open and close it too: a changed expanded attribute wins, expanded="false" closes it, and a removed attribute changes nothing, so a morph that drops it doesn't close the reader's view. With Datastar, bind it as data-attr:expanded="$_full ? 'true' : 'false'": data-attr removes the attribute for false, and a removed attribute changes nothing. Opened that way, the focus moves to the divider, and the close button shows even without expandable. Moved or re-inserted (a morph does both), the element keeps full screen, the divider's position and the server's last word. Full screen shows the images the browser already chose for the page: with srcset, list a candidate as wide as the screen and keep sizes honest, or the picture is upscaled.
<sb-image-compare expandable before-label="Infrared" after-label="Visible" style="inline-size: min(100%, 30rem)">
<img slot="before" src="/art/landscape.svg" alt="Planet X-9 in infrared: the hills glow, the sky is dark" width="256" height="144" style="image-rendering: pixelated; filter: hue-rotate(150deg) saturate(1.6)">
<img slot="after" src="/art/landscape.svg" alt="Planet X-9 in visible light: a violet planet rising over violet hills" width="256" height="144" style="image-rendering: pixelated">
</sb-image-compare>
Any two elements: two themes
The sides don't have to be images. Here the same card is rendered twice, once in Deep Space and once in Daylight, each scoped to its side with data-sb-theme.
<sb-image-compare before-label="Deep Space" after-label="Daylight" style="inline-size: min(100%, 24rem)">
<div slot="before" data-sb-theme="deep-space" style="padding: 20px; background: var(--sb-bg)">
<sb-card heading="Planet X-9">
<img slot="media" src="/art/landscape.svg" alt="" width="256" height="144">
A cold, quiet world with excellent stargazing.
<sb-button slot="footer" size="sm" variant="pixel">Visit</sb-button>
</sb-card>
</div>
<div slot="after" data-sb-theme="daylight" style="padding: 20px; background: var(--sb-bg)">
<sb-card heading="Planet X-9">
<img slot="media" src="/art/landscape.svg" alt="" width="256" height="144">
A cold, quiet world with excellent stargazing.
<sb-button slot="footer" size="sm" variant="pixel">Visit</sb-button>
</sb-card>
</div>
</sb-image-compare>
Both sides must render at the same size: give them the same markup, or fixed dimensions.
Steered by Datastar
position is an attribute, so a signal can drive it, and sb-position reports where the reader left the divider. data-preserve-attr keeps the bound attribute when a server frame morphs the page.
<div data-signals="{_phase: 50}" style="display: grid; gap: 16px; inline-size: min(100%, 18rem)">
<sb-image-compare before-label="Night side" after-label="Day side"
data-attr:position="$_phase" data-preserve-attr="position"
data-on:sb-position="$_phase = evt.detail.position">
<img slot="before" src="/art/moon.svg" alt="The moon's night side, almost black" width="210" height="210" style="image-rendering: pixelated; filter: brightness(0.3) saturate(0.4)">
<img slot="after" src="/art/moon.svg" alt="The moon's day side, grey and cratered" width="210" height="210" style="image-rendering: pixelated">
</sb-image-compare>
<sb-slider label="Phase" unit="%" data-bind:_phase__prop.value></sb-slider>
</div>
A new position from the page or the server replaces the reader's, even one equal to the default; re-sending the same markup changes nothing. The live position is the position property.
Before the module has loaded, both sides show stacked. The autoloader's sb-cloak class on <html> hides undefined components until then; without it, hide sb-image-compare:not(:defined) [slot="before"] yourself.
Styling
Style it from your page's CSS, without changing the component or importing anything into it. Custom properties, inherited properties and ::part() all reach into its shadow root.
- Size: the comparison is as wide as its container and as tall as the
afterside at that width. Setinline-size(ormax-inline-size) on the element. - 8-bit details: the picture sits in a notched pixel frame in
--sb-frame-colorwith a hard drop shadow,--sb-frame-stepthick (1pxmakes it a hairline,0removes it).--sb-notchnotches the grip, the tags and the button (0rounds them), and the tags use--sb-font-display.data-sb-style="smooth"turns all three off. - Colours:
--sb-image-compare-linecolours the divider,--sb-image-compare-handleand--sb-image-compare-handle-inkthe grip and its arrows (by default--sb-text-1on--sb-bg), with a bevel in--sb-brand-light. The tags and the button are--sb-text-1on a translucent--sb-bg. Full screen is--sb-bgwith stars in--sb-text-mutedand--sb-text-1;--sb-image-compare-stars: noneleaves the background plain. - Parts:
frame(also the full screen view),stage(the framed picture),before,after,divider,handle,tag(both tags;before-tag,after-tagfor one), andexpand(the button). Your page's::part()rules win over the component's own, without!important.
<style>
.my-compare {
--sb-frame-color: #FFD166;
--sb-image-compare-line: #FFD166;
--sb-image-compare-handle: #FFD166;
--sb-notch: 0;
inline-size: min(100%, 20rem);
}
.my-compare::part(tag) { font-family: Georgia, serif; text-transform: none; letter-spacing: 0; }
</style>
<sb-image-compare class="my-compare" before-label="Mono" after-label="Colour">
<img slot="before" src="/art/saturn.svg" alt="A ringed planet in grey" width="260" height="180" style="image-rendering: pixelated; filter: grayscale(1)">
<img slot="after" src="/art/saturn.svg" alt="A violet planet with blue rings" width="260" height="180" style="image-rendering: pixelated">
</sb-image-compare>
Accessibility
The divider is a native range input, invisible over the picture: Tab focuses it (the frame gets a focus ring), the arrow keys move it by 1%, Page Up/Down by 10%, Home and End to either edge. Its value text names both sides; label names the control, else the element's own aria-label, else "Comparison". Pointer drags move the same value, and after a drag or a tap the divider has the focus, so the keys continue from there.
On touch screens a horizontal drag moves the divider and a vertical swipe scrolls the page without moving it. Full screen is announced as a dialog named like the divider (label, else the element's aria-label, else "Comparison"); expand-label and close-label name the button in your language. In right-to-left text the before side sits on the right.
Give each image its own alt text: a reader who can't see the pictures needs to know what differs.
Installation
Add Datastar with Rocket and the Starbase autoloader once per page, then use the tag: the autoloader imports each component the first time its tag appears, including tags a Datastar morph adds later. The Datastar here is v1.0.4 with Starbase's fixes until they are released; the components also run on the official release.
<script type="importmap">
{ "imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" } }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/autoloader.js"></script>
<sb-image-compare before-label="Before" after-label="After" expandable>
<img slot="before" src="before.png" alt="The page before the change">
<img slot="after" src="after.png" alt="The page after the change">
</sb-image-compare>No flash of undefined elements: put class="sb-cloak" on <html> and add .sb-cloak :not(:defined) { visibility: hidden } to your CSS. The autoloader removes the class once the page's components are defined.
Load just this component, pinned to this version: the minified module with its integrity hash, so the browser refuses it if a single byte changes.
<script type="importmap">
{
"imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" },
"integrity": {
"https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js": "sha384-mvTnynHaljKNMleD0bj6/rWROnluHlNmbc5rOQTd+fQH3YBDYhlvHdNquEX99by5",
"https://starbase.zweiundeins.gmbh/c/image-compare@44e4e2cae843/image-compare.min.js": "sha384-N5ZDXXg8m+MkJtR19IzEY5sJntGV7H/YKSInuTNuDzeE0snjrTGYNw2MqQRajBvd"
}
}
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/image-compare@44e4e2cae843/image-compare.min.js" integrity="sha384-N5ZDXXg8m+MkJtR19IzEY5sJntGV7H/YKSInuTNuDzeE0snjrTGYNw2MqQRajBvd"></script>
<sb-image-compare before-label="Before" after-label="After" expandable>
<img slot="before" src="before.png" alt="The page before the change">
<img slot="after" src="after.png" alt="The page after the change">
</sb-image-compare>In production, pin today's whole catalog: its autoloader and an import map with integrity hashes, so the browser refuses any file that changed. This map covers Datastar and this component; importmap.json has the hashes of every component, to merge into yours.
<script type="importmap">
{
"imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" },
"integrity": {
"https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js": "sha384-mvTnynHaljKNMleD0bj6/rWROnluHlNmbc5rOQTd+fQH3YBDYhlvHdNquEX99by5",
"https://starbase.zweiundeins.gmbh/c/image-compare@44e4e2cae843/image-compare.min.js": "sha384-N5ZDXXg8m+MkJtR19IzEY5sJntGV7H/YKSInuTNuDzeE0snjrTGYNw2MqQRajBvd"
}
}
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/@c165875fb681/autoloader.js" integrity="sha384-GSnXFTGY2G0KaLoU4ay3eO2eDT2deAiOwFYw6q+cyzLfqQzEKw47znHC9xsLt/VR"></script>
<sb-image-compare before-label="Before" after-label="After" expandable>
<img slot="before" src="before.png" alt="The page before the change">
<img slot="after" src="after.png" alt="The page after the change">
</sb-image-compare>Components import only datastar and their own files, so copying the folder is enough: save these files, keeping each component's folder, and point the import map at your own copy of datastar-rocket.js (the /js/ paths stand for yours).
<script type="importmap">
{ "imports": { "datastar": "/js/datastar-rocket.js" } }
</script>
<script type="module" src="/js/image-compare/image-compare.min.js"></script>
<sb-image-compare before-label="Before" after-label="After" expandable>
<img slot="before" src="before.png" alt="The page before the change">
<img slot="after" src="after.png" alt="The page after the change">
</sb-image-compare>Size
Each file compressed on its own, the way it is served (gzip -9, brotli -11). The autoloader loads the minified files (esbuild), so that last column is what a page downloads; the readable source is always there too. Datastar and Rocket are shared by every component and not counted.
| File | Original | gzip | brotli | minified |
|---|---|---|---|---|
image-compare.js | 20.9 kB | 6.6 kB | 5.7 kB | 3.6 kB |
API reference
Props
| Attribute | Type | Default | Description |
|---|---|---|---|
position | number | 50 | Where the divider sits, in percent from the start edge. A new position from the page or the server replaces the reader's. |
before-label | string | "" | Tag over the before side (slot "before", on the start side of the divider). |
after-label | string | "" | Tag over the after side (slot "after", on the end side of the divider). |
label | string | "" | Accessible name of the divider, and of the full screen view. Without it, the element's aria-label, else "Comparison". |
expandable | boolean | false | Show a button that opens the comparison full screen (Escape closes it). |
expanded | boolean | false | Full screen, as view state: a changed attribute from the page or the server opens or closes it, a removed one is ignored. The reader's own opening and closing never touch it; sb-expand reports them. |
expand-label | string | "Full screen" | Accessible name of the full screen button. |
close-label | string | "Exit full screen" | Accessible name of the button while full screen. |
Slots
| Name | Description |
|---|---|
before | The before side, on the start side of the divider: an <img>, a <picture> (the page sizes its <img>), or any element. |
after | The after side, on the end side. It sets the size of the comparison. |
Events
| Name | Description |
|---|---|
sb-position | After a drag, a tap or a key moved the divider. detail: { position } (percent). |
sb-expand | When full screen opens or closes. detail: { expanded, reason }, reason being 'reader' (button, Escape), 'server' (the expanded attribute) or 'api' (the expanded property). |