Image Compare
Before/after comparison whose top layer is clipped by a spring-smoothed clip-path divider, with a draggable knob, optional hover-follow mode, edge-fading labels, keyboard slider control, and images or any React node on either side.
Preview
Install
Add it with the shadcn CLI, or copy the source manually.
$ bunx --bun shadcn add @karaui/image-compare
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
TSXlib/ease.ts
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
TSXlib/touch.ts
// Shared touch primitives. iOS and iPadOS run their own gestures on top of the
// page — the long-press selection callout and the selection it drags in with
// it — and they win: once the platform claims a touch it cancels ours
// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own
// their gesture have to opt out.
//
// What the two classes below cover, precisely:
// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:
// it is not a property other engines have, so it is inert everywhere else.
// - `user-select: none` stops the long-press selection on every engine,
// Android included, and stops a drag from painting a selection under the
// cursor. It is inherited, so it reaches every descendant — which is why the
// two classes differ only in whether they apply it unconditionally.
// What neither covers:
// - Chrome for Android's long-press menu on a link or an image. No CSS
// suppresses it; a gesture surface that wraps one needs its own
// `onContextMenu` with `preventDefault()`.
// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is
// not inherited and plain divs and buttons are not drag sources, so setting
// it on the surface does nothing — the child itself needs `draggable={false}`.
/**
* Classes for a surface that *is* the control: a thumb, a drum, a stage, a
* handle, a hold button. Selection is suppressed on every input, because a
* drag that highlights the control's own label is wrong on a mouse too.
* Compose with `touch-none` when the surface also owns the scroll axis — leave
* it off when the page must still scroll from there.
*/
export const TOUCH_GESTURE_CLASS = "select-none [-webkit-touch-callout:none]";
/**
* The same opt-out for a gesture surface that wraps content the consumer owns:
* a scroller, a context-menu trigger, a sheet header, a list row. Selection is
* suppressed only where the platform runs its own press gestures — a coarse
* pointer — so a mouse user can still select and copy that content. If the
* gesture itself would paint a selection under the cursor, add `select-none`
* for the duration of the gesture rather than reaching for
* `TOUCH_GESTURE_CLASS`.
*
* `pointer: coarse` describes the *primary* pointer and nothing else, so a
* hybrid machine reads it wrong in both directions: a tablet with a mouse
* plugged in keeps touch as primary and loses mouse selection, and a laptop
* with a touchscreen keeps the mouse as primary and leaves selection live
* under a finger. No media query can answer per interaction — the query is
* about the device, and the question is about the gesture in progress. The
* default stays here because it is right on the machines that are one thing or
* the other, and losing a selection is a nuisance; where the miss costs a
* *gesture* instead, the surface pairs it with `holdSelection` on the press.
*/
export const TOUCH_GESTURE_CONTENT_CLASS =
"[-webkit-touch-callout:none] pointer-coarse:select-none";
/**
* Grow a small control's *hit area* to at least 44×44px (WCAG 2.5.5, Apple
* HIG) on a coarse pointer, without changing what it looks like or the space
* it takes in layout. An invisible `::after` box, centered on the control and
* `max(100%, 2.75rem)` on each axis, catches the taps: a 32px icon button
* reaches 6px past each edge, a wide 40px button only grows vertically, and a
* control already 44px or larger is left as it is. Hit-testing a pseudo
* element lands on its owner, so handlers, focus and `:active` all behave.
*
* Mouse and trackpad rendering is untouched — nothing here applies outside
* `pointer: coarse`. Requirements on the element it goes on:
* - It must be the containing block: add `relative` (and keep it mergeable, so
* a consumer's `absolute` still wins through `cn`).
* - It must not clip itself (`overflow-hidden`), or the overhang is clipped
* away along with its hit area.
* - It must render children: `<input>` and other void elements have no
* `::after`. Put it on the wrapper or label instead.
* - It must not already use `::after` for something visible.
*
* Centered 44px means two 32px buttons with a 4px gap overlap by 8px; the
* later one wins that strip, which still ends well short of the earlier
* button's visual center. Below a ~32px pitch the later neighbour starts
* eating into that center — use `TOUCH_TARGET_Y_CLASS` for such dense rows.
*
* The overhang is real geometry: inside a scroll container it counts as
* scrollable overflow, so a control flush against a scroller's edge can add a
* few pixels of scroll on touch screens. Give such a scroller a little padding
* on that side.
*/
export const TOUCH_TARGET_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-[max(100%,2.75rem)] pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";
/**
* `TOUCH_TARGET_CLASS` on the vertical axis only, for controls packed tighter
* than ~32px apart in a row (pagination dots, dense swatches), where growing
* sideways would hand most of each control to its neighbour. Same rules.
*/
export const TOUCH_TARGET_Y_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:top-1/2 pointer-coarse:after:h-[max(100%,2.75rem)] pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";
/**
* Suppress selection on `element` for as long as a gesture is running on it,
* whatever the primary pointer of the machine happens to be. Returns the
* release. Inline, so it wins over the class above and is gone again the
* moment the gesture ends.
*
* For the press gestures a native selection would otherwise steal — a
* long-press that opens a menu. Elsewhere prefer the classes: a surface that
* takes selection away for the whole session is a surface whose text nobody
* can copy.
*/
export function holdSelection(element: HTMLElement) {
element.style.setProperty("user-select", "none");
element.style.setProperty("-webkit-user-select", "none");
return () => {
element.style.removeProperty("user-select");
element.style.removeProperty("-webkit-user-select");
};
}
/**
* Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer
* is already gone by the time the handler runs — routine on iOS, where the
* system can claim the touch first — and an uncaught throw takes the rest of
* the handler, the gesture included, down with it. Touch pointers carry
* implicit capture anyway, so losing it is never fatal.
*/
export function capturePointer(element: Element, pointerId: number) {
try {
element.setPointerCapture(pointerId);
} catch {
// Pointer is no longer active — implicit capture still applies on touch.
}
}
/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */
export function releasePointer(element: Element, pointerId: number) {
try {
if (element.hasPointerCapture(pointerId)) {
element.releasePointerCapture(pointerId);
}
} catch {
// Capture was already dropped by the browser.
}
}
/**
* Whether this event came from a pointer that is *hovering*: not a touch, and
* not currently pressed. Which input the user is holding right now is not
* something a device capability can answer — a touchscreen laptop hovers and
* taps, and iPadOS reports a fine hovering pointer for a finger — so both
* paths stay live and each handler branches on the event it was given.
*
* A pen resting on the glass is making contact, not hovering: `buttons` is the
* tell, and it sends a pen tap down the same route a finger takes.
*
* This answers what an *enter* asks. A leave is the other half of a pair and
* has to be read against the enter that started it — `useHoverGesture` in
* `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it
* rather than asking this question twice.
*/
export const isHoveringPointer = (event: {
pointerType: string;
buttons: number;
}) => event.pointerType !== "touch" && event.buttons === 0;
TSXlib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
TSXcomponents/motion/image-compare.tsx
"use client";
// karaui.kamisama-v0.my.id/components/motion/image-compare
import { ChevronLeft, ChevronRight } from "lucide-react";
import {
motion,
useMotionTemplate,
useMotionValue,
useReducedMotion,
useSpring,
useTransform,
} from "motion/react";
import {
type KeyboardEvent,
type PointerEvent,
type ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { SPRING_GLIDE, SPRING_PRESS } from "@/lib/ease";
import {
capturePointer,
isHoveringPointer,
releasePointer,
TOUCH_GESTURE_CLASS,
} from "@/lib/touch";
import { cn } from "@/lib/utils";
export interface ImageCompareProps {
/** Left / underneath layer. An image URL or any node. */
before: ReactNode | string;
/** Right / clipped top layer. An image URL or any node. */
after: ReactNode | string;
/** Alt text when `before` is a URL. */
beforeAlt?: string;
/** Alt text when `after` is a URL. */
afterAlt?: string;
/** Divider position in percent (controlled). */
value?: number;
defaultValue?: number;
onValueChange?: (value: number) => void;
/** Divider follows the pointer while hovering instead of needing a drag. */
hover?: boolean;
beforeLabel?: ReactNode;
afterLabel?: ReactNode;
showLabels?: boolean;
"aria-label"?: string;
className?: string;
}
const KEY_STEP = 1;
const KEY_STEP_LARGE = 10;
const clamp = (v: number) => Math.min(100, Math.max(0, v));
function Layer({ content, alt }: { content: ReactNode | string; alt?: string }) {
if (typeof content === "string") {
return (
// biome-ignore lint/performance/noImgElement: keeps the component framework-agnostic and both layers pixel-aligned.
<img
src={content}
alt={alt ?? ""}
draggable={false}
className="absolute inset-0 size-full object-cover"
/>
);
}
return <div className="absolute inset-0 size-full">{content}</div>;
}
export function ImageCompare({
before,
after,
beforeAlt,
afterAlt,
value,
defaultValue = 50,
onValueChange,
hover = false,
beforeLabel = "Before",
afterLabel = "After",
showLabels = true,
"aria-label": ariaLabel = "Comparison divider",
className,
}: ImageCompareProps) {
const reduce = useReducedMotion();
const stageRef = useRef<HTMLDivElement>(null);
const handleRef = useRef<HTMLDivElement>(null);
const [internal, setInternal] = useState(clamp(defaultValue));
const controlled = value !== undefined;
const percent = clamp(controlled ? value : internal);
const [dragging, setDragging] = useState(false);
const target = useMotionValue(percent);
useEffect(() => {
target.set(percent);
}, [percent, target]);
const smooth = useSpring(target, SPRING_GLIDE);
const pos = reduce ? target : smooth;
// Clip the top ("after") layer from the left edge up to the divider.
const clipPath = useMotionTemplate`inset(0 0 0 ${pos}%)`;
const left = useMotionTemplate`${pos}%`;
// Chips fade as their side shrinks toward the edge.
const beforeOpacity = useTransform(pos, [4, 18], [0, 1]);
const afterOpacity = useTransform(pos, [82, 96], [1, 0]);
const setValue = useCallback(
(next: number) => {
const v = Math.round(clamp(next) * 10) / 10;
if (!controlled) setInternal(v);
onValueChange?.(v);
},
[controlled, onValueChange],
);
const fromPointer = (clientX: number) => {
const rect = stageRef.current?.getBoundingClientRect();
if (!rect?.width) return;
setValue(((clientX - rect.left) / rect.width) * 100);
};
const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
if (event.pointerType === "mouse" && event.button !== 0) return;
event.preventDefault();
capturePointer(event.currentTarget, event.pointerId);
handleRef.current?.focus({ preventScroll: true });
setDragging(true);
fromPointer(event.clientX);
};
const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
if (dragging || (hover && isHoveringPointer(event))) {
fromPointer(event.clientX);
}
};
const endDrag = (event: PointerEvent<HTMLDivElement>) => {
releasePointer(event.currentTarget, event.pointerId);
setDragging(false);
};
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
const step = event.shiftKey ? KEY_STEP_LARGE : KEY_STEP;
let next: number | null = null;
switch (event.key) {
case "ArrowLeft":
case "ArrowDown":
next = percent - step;
break;
case "ArrowRight":
case "ArrowUp":
next = percent + step;
break;
case "PageDown":
next = percent - KEY_STEP_LARGE;
break;
case "PageUp":
next = percent + KEY_STEP_LARGE;
break;
case "Home":
next = 0;
break;
case "End":
next = 100;
break;
default:
return;
}
event.preventDefault();
setValue(next);
};
return (
<div
ref={stageRef}
data-dragging={dragging || undefined}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
className={cn(
"relative isolate aspect-video w-full touch-pan-y overflow-hidden rounded-2xl bg-muted",
TOUCH_GESTURE_CLASS,
hover ? "cursor-ew-resize" : "cursor-grab data-dragging:cursor-grabbing",
className,
)}
>
<Layer content={before} alt={beforeAlt} />
<motion.div className="absolute inset-0" style={{ clipPath }}>
<Layer content={after} alt={afterAlt} />
</motion.div>
{showLabels ? (
<>
<motion.span
aria-hidden
style={{ opacity: beforeOpacity }}
className="pointer-events-none absolute top-3 left-3 rounded-full bg-black/55 px-2.5 py-1 text-[11px] font-medium text-white backdrop-blur-sm"
>
{beforeLabel}
</motion.span>
<motion.span
aria-hidden
style={{ opacity: afterOpacity }}
className="pointer-events-none absolute top-3 right-3 rounded-full bg-black/55 px-2.5 py-1 text-[11px] font-medium text-white backdrop-blur-sm"
>
{afterLabel}
</motion.span>
</>
) : null}
<motion.div
aria-hidden
className="pointer-events-none absolute inset-y-0 w-0.5 -translate-x-1/2 bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.12)]"
style={{ left }}
/>
<motion.div
ref={handleRef}
role="slider"
tabIndex={0}
aria-label={ariaLabel}
aria-orientation="horizontal"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(percent)}
aria-valuetext={`${Math.round(percent)}% before, ${Math.round(100 - percent)}% after`}
onKeyDown={onKeyDown}
style={{ left }}
animate={{ scale: dragging && !reduce ? 1.08 : 1 }}
transition={SPRING_PRESS}
className={cn(
"absolute top-1/2 grid size-10 -translate-x-1/2 -translate-y-1/2 grid-flow-col place-items-center rounded-full bg-white text-neutral-900 shadow-lg outline-none",
"ring-offset-2 focus-visible:ring-2 focus-visible:ring-white/90",
)}
>
<ChevronLeft aria-hidden className="-mr-1 size-4" />
<ChevronRight aria-hidden className="-ml-1 size-4" />
</motion.div>
</div>
);
}
@karaui not found? Add it to components.json
{
"registries": {
"@karaui": "https://karaui.kamisama-v0.my.id/r/{name}.json"
}
}@karaui is in shadcn's public registry directory, so the latest CLI needs no setup. On an older CLI, merge this into the existing file once and every @karaui/… install resolves.
API Reference
beforeReactNodeLeft / underneath layer. An image URL or any node.
—afterReactNodeRight / clipped top layer. An image URL or any node.
—beforeAlt?stringAlt text when `before` is a URL.
—afterAlt?stringAlt text when `after` is a URL.
—value?numberDivider position in percent (controlled).
—defaultValue?number50onValueChange?((value: number) => void)—hover?booleanDivider follows the pointer while hovering instead of needing a drag.
falsebeforeLabel?ReactNodeBeforeafterLabel?ReactNodeAftershowLabels?booleantruearia-label?stringComparison dividerclassName?string—Updated