Drawer
UIA bottom sheet that slides up over a scrim and can be dragged closed: it follows the finger, and on release springs back or leaves depending on distance and velocity, interruptible mid-flight.
Components / UI
Compares a before and an after image with a line you drag between them. A native range input lies over the box, invisible, so dragging, clicking to jump, the arrow keys, touch and the screen reader are the browser's; the script writes its value to one custom property and the after image is clipped to it. Held, it follows with no lag; stepped, it glides on the fast token.
drag · click · ← →
Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.
Adjust · props are custom properties, set inline, so you edit the running instance
JavaScript of its own
205 B raw
177 B gzip.
Uses the shared runtime (1.9 kB raw, once per site). With those included: 1.9 kB raw.
CSS 7.5 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.
Browser support
Chrome 124 · Firefox 121 · Safari 16.5; needs clip-path, has, range-vertical. Elsewhere: without :has() the line glides while held too; without vertical ranges, orientation="vertical" keeps a horizontal range over the box, so its line follows a sideways drag.
One command adds the integration, the base tokens and every component. Then import what you use.
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarc---
import ImageCompare from '@moonarc/core/ImageCompare';
---
<ImageCompare
before="/work/sketch.jpg"
after="/work/final.jpg"
beforeAlt="Pencil sketch of the poster"
afterAlt="The printed poster"
start={40}
/>
<ImageCompare before={a} after={b} orientation="vertical" labels={['Day', 'Night']} />Owns the file, no dependency. The registry item also installs the shared runtime and the base tokens.
npx shadcn@latest add https://moonarc.dev/r/image-compare.jsonThe CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.
The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy runtime.ts.
---
/**
* ImageCompare — before and after, a line you drag between them. A native
* <input type="range"> lies over the whole box, invisible: the drag, the
* click-to-jump, the arrow keys, the touch and the screen reader are the
* browser's. The script writes the value to one custom property; the
* "after" image is clipped to it with clip-path: inset() and the handle
* sits on it. While the thumb is held there is no transition; a keyboard
* step or a click glides on the fast token. Without JavaScript the line
* stands at `start` and both halves show. Right to left, the range runs
* from the right, so "before" sits on the inline start (the right) and the
* line still follows the pointer.
*/
import type { HTMLAttributes } from 'astro/types';
interface Props extends HTMLAttributes<'div'> {
/** The image under the inline-start (left; right on a right-to-left page) or top half. */
before: string;
/** The image under the inline-end or bottom half. */
after: string;
beforeAlt?: string;
afterAlt?: string;
/** Initial position, 0–100. */
start?: number;
orientation?: 'horizontal' | 'vertical';
/** Corner labels; false hides them. */
labels?: [string, string] | false;
/** Accessible name of the slider. */
label?: string;
}
const { before, after, beforeAlt = '', afterAlt = '', start = 50, orientation = 'horizontal', labels = ['Before', 'After'], label = 'Compare', class: className, style, ...rest } = Astro.props;
const inline = [`--ma-imgcmp:${Math.max(0, Math.min(100, start))}%`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---
<div class:list={['ma-imgcmp', className]} data-ma-imgcmp data-orientation={orientation} style={inline} {...rest}>
<img class="ma-imgcmp__before" src={before} alt={beforeAlt} draggable="false" />
<img class="ma-imgcmp__after" src={after} alt={afterAlt} draggable="false" />
{labels && (
<>
<span class="ma-imgcmp__label ma-imgcmp__label--a" aria-hidden="true">{labels[0]}</span>
<span class="ma-imgcmp__label ma-imgcmp__label--b" aria-hidden="true">{labels[1]}</span>
</>
)}
<span class="ma-imgcmp__handle" aria-hidden="true">
<span class="ma-imgcmp__knob">
<svg viewBox="0 0 20 20" width="16" height="16"><path d="M7 6l-4 4 4 4M13 6l4 4-4 4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" /></svg>
</span>
</span>
<input type="range" class="ma-imgcmp__range" min="0" max="100" step="0.5" value={start} aria-label={label} />
</div>
<style is:global>
@layer components {
:where(.ma-imgcmp) {
position: relative;
display: grid;
overflow: clip;
border-radius: 0.75rem;
background: var(--ma-edge);
user-select: none;
-webkit-user-select: none;
}
:where(.ma-imgcmp__before),
:where(.ma-imgcmp__after) {
grid-area: 1 / 1;
display: block;
width: 100%;
height: auto;
object-fit: cover;
}
/* the after image shows from the line to the right; while the thumb is held the clip follows with no lag */
:where(.ma-imgcmp__after) {
clip-path: inset(0 0 0 var(--ma-imgcmp, 50%));
transition: clip-path var(--ma-duration-fast) var(--ma-ease-out);
}
:where(.ma-imgcmp__handle) {
position: absolute;
top: 0;
bottom: 0;
left: var(--ma-imgcmp, 50%);
display: grid;
place-items: center;
width: 2px;
background: var(--ma-panel);
box-shadow: 0 0 0 1px rgb(0 0 0 / 0.15);
translate: -50% 0;
pointer-events: none;
transition: left var(--ma-duration-fast) var(--ma-ease-out);
}
:where(.ma-imgcmp:has(.ma-imgcmp__range:active) .ma-imgcmp__after),
:where(.ma-imgcmp:has(.ma-imgcmp__range:active) .ma-imgcmp__handle) {
transition: none;
}
:where(.ma-imgcmp__knob) {
display: grid;
place-items: center;
width: 2.5rem;
height: 2.5rem;
border: 1px solid var(--ma-edge);
border-radius: 999px;
background: var(--ma-panel);
color: var(--ma-ink);
box-shadow: 0 8px 24px -12px rgb(0 0 0 / 0.5);
transition: scale var(--ma-duration) var(--ma-ease), box-shadow var(--ma-duration-fast) var(--ma-ease-out);
}
:where(.ma-imgcmp:has(.ma-imgcmp__range:active) .ma-imgcmp__knob),
:where(.ma-imgcmp:has(.ma-imgcmp__range:hover) .ma-imgcmp__knob) {
scale: 1.08;
}
:where(.ma-imgcmp:has(.ma-imgcmp__range:focus-visible) .ma-imgcmp__knob) {
box-shadow: 0 0 0 3px var(--ma-ink);
}
/* the real control, invisible over the whole box: drag, click, keys, touch and the screen reader are native */
:where(.ma-imgcmp__range) {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: ew-resize;
touch-action: pan-y;
}
:where(.ma-imgcmp__label) {
position: absolute;
top: 0.75rem;
padding: 0.25em 0.6em;
border-radius: 999px;
background: var(--ma-panel);
color: var(--ma-ink);
font-family: ui-monospace, monospace;
font-size: 0.6875rem;
letter-spacing: 0.06em;
text-transform: uppercase;
pointer-events: none;
}
:where(.ma-imgcmp__label--a) {
inset-inline-start: 0.75rem;
}
:where(.ma-imgcmp__label--b) {
inset-inline-end: 0.75rem;
}
/* right to left: a horizontal range runs from the right, so the value is measured from the right edge and "after" shows to
the left of the line (inset() and translate have no logical form). [dir] and not :dir(): Chrome has :dir() only
from 120, and :where() drops it silently before that. An ltr island one level inside an rtl page is set back */
:where([dir='rtl'] .ma-imgcmp:not([data-orientation='vertical']) .ma-imgcmp__after, .ma-imgcmp[dir='rtl']:not([data-orientation='vertical']) .ma-imgcmp__after) {
clip-path: inset(0 var(--ma-imgcmp, 50%) 0 0);
}
:where([dir='rtl'] .ma-imgcmp:not([data-orientation='vertical']) .ma-imgcmp__handle, .ma-imgcmp[dir='rtl']:not([data-orientation='vertical']) .ma-imgcmp__handle) {
left: auto;
right: var(--ma-imgcmp, 50%);
translate: 50% 0;
transition-property: right;
}
:where([dir='rtl'] [dir='ltr'] .ma-imgcmp:not([data-orientation='vertical']) .ma-imgcmp__after, [dir='rtl'] .ma-imgcmp[dir='ltr']:not([data-orientation='vertical']) .ma-imgcmp__after) {
clip-path: inset(0 0 0 var(--ma-imgcmp, 50%));
}
:where([dir='rtl'] [dir='ltr'] .ma-imgcmp:not([data-orientation='vertical']) .ma-imgcmp__handle, [dir='rtl'] .ma-imgcmp[dir='ltr']:not([data-orientation='vertical']) .ma-imgcmp__handle) {
left: var(--ma-imgcmp, 50%);
right: auto;
translate: -50% 0;
transition-property: left;
}
/* vertical: the line runs across; the range is written top to bottom */
:where(.ma-imgcmp[data-orientation='vertical'] .ma-imgcmp__after) {
clip-path: inset(var(--ma-imgcmp, 50%) 0 0 0);
}
:where(.ma-imgcmp[data-orientation='vertical'] .ma-imgcmp__handle) {
top: var(--ma-imgcmp, 50%);
bottom: auto;
left: 0;
right: 0;
width: auto;
height: 2px;
translate: 0 -50%;
transition: top var(--ma-duration-fast) var(--ma-ease-out);
}
:where(.ma-imgcmp[data-orientation='vertical'] .ma-imgcmp__knob svg) {
rotate: 90deg;
}
/* direction: ltr keeps the vertical range top to bottom on a right-to-left page too (rtl would run it bottom to top) */
:where(.ma-imgcmp[data-orientation='vertical'] .ma-imgcmp__range) {
writing-mode: vertical-lr;
direction: ltr;
cursor: ns-resize;
touch-action: pan-x;
}
:where(.ma-imgcmp[data-orientation='vertical'] .ma-imgcmp__label--b) {
top: auto;
bottom: 0.75rem;
inset-inline-end: auto;
inset-inline-start: 0.75rem;
}
@media (prefers-reduced-motion: reduce) {
:where(.ma-imgcmp__after),
:where(.ma-imgcmp__handle),
:where(.ma-imgcmp__knob) {
transition: none;
}
}
}
</style>
<script>
import { onMount } from '../lib/runtime';
onMount<HTMLElement>('[data-ma-imgcmp]', (el, { signal }) => {
const range = el.querySelector('input')!;
range.addEventListener('input', () => el.style.setProperty('--ma-imgcmp', `${range.value}%`), { signal });
});
</script>| Prop | Type | Default | Description |
|---|---|---|---|
before | string | none | The image on the inline start (the left, or the right on a right-to-left page), or the top. |
after | string | none | The image on the inline end, or the bottom. |
beforeAlt | string | '' | Alt text of the before image. |
afterAlt | string | '' | Alt text of the after image. |
start | number | 50 | Initial position of the line, 0–100. Without JavaScript the line stands here. |
orientation | 'horizontal' | 'vertical' | 'horizontal' | Which way the line runs; vertical writes the range top to bottom. |
labels | [string, string] | false | ['Before', 'After'] | Corner labels. |
label | string | 'Compare' | Accessible name of the slider. |
No gliding: the line moves at once on a key or a click too.
ClientRouterBound through the shared runtime; one input listener per instance, aborted before the swap.
Replaces: Compare (Aceternity) · ImageComparison (Motion Primitives) · react-compare-image · Juxtapose. See the migration table.
A bottom sheet that slides up over a scrim and can be dragged closed: it follows the finger, and on release springs back or leaves depending on distance and velocity, interruptible mid-flight.
A thumbnail gallery whose images open full size: the image grows out of its own thumbnail on a same-document view transition and returns to it on close, and ← → move through the set.
An image that starts narrow and rounded and opens to the full width as it scrolls into view.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown