Compare

Two layers of one frame with a seam you drag across — before and after, revealed rather than resized.

Open Source

Installation

Compare ships with the library — install it once, then import:

import { Compare } from "@delacour/react-native-ui/compare";

Or copy the source into your project, to own and edit it:

bunx delacour@latest add compare

Usage

app/index.tsx
import { Compare } from "@/components/ui/compare";
import { Image, View } from "react-native";

const original = { uri: "https://example.com/harbour-original.jpg" };
const retouched = { uri: "https://example.com/harbour-retouched.jpg" };

export default function Example() {
  return (
    <View className="flex-1 justify-center p-4">
      <Compare accessibilityLabel="Retouch" className="aspect-[4/3]">
        <Compare.Before>
          <Image accessibilityLabel="Original photo" className="size-full" source={original} />
        </Compare.Before>
        <Compare.After>
          <Image accessibilityLabel="Retouched photo" className="size-full" source={retouched} />
        </Compare.After>
        <Compare.Label side="before">Original</Compare.Label>
        <Compare.Label side="after">Retouched</Compare.Label>
        <Compare.Handle />
      </Compare>
    </View>
  );
}

Compare.Before sits on the leading side — left, top when vertical, right under RTL — and Compare.After fills the frame behind it. A position of 1 shows only Before, 0 only After.

Both layers are laid out once, at the full frame size, and only a mask moves. The halves stay in register at every position: an image is revealed, never squashed, and text never reflows.

Anatomy

The frame has no size of its own

Give className an aspect-* or an h-*. Without one the frame collapses to nothing.

Themes

Each side takes any children — not only images. One Card under a light and a dark ScopedTheme is the realistic case: the seam cuts one card in half.

One card, two themes
import { ScopedTheme } from "uniwind";

<Compare accessibilityLabel="Light and dark theme" className="h-64">
  <Compare.Before>
    <ScopedTheme theme="light">
      <ReportCard />
    </ScopedTheme>
  </Compare.Before>
  <Compare.After>
    <ScopedTheme theme="dark">
      <ReportCard />
    </ScopedTheme>
  </Compare.After>
  <Compare.Handle />
</Compare>

Vertical

Vertical
<Compare accessibilityLabel="Sky" className="h-72" orientation="vertical">
  …
</Compare>

A vertical comparison needs a definite height

It fills the height it is given, so the frame needs an h-* — an aspect ratio alone is measured from a width a vertical frame does not drag across.

Colours and sizes

color paints the divider and the grip capsule; the glyph takes that colour's foreground. size moves the capsule only — the handle's touch band is 44pt at every size.

Colours and sizes
Colours and sizes
<Compare color="default" size="sm" />
<Compare color="primary" size="md" />
<Compare color="success" size="lg" />
<Compare color="warning" />
<Compare color="destructive" />
<Compare color="info" />

Snap points

A release within snapThreshold of a point in snapPoints lands on it, with a haptic tick. Snapping waits for the release; a seam that snapped mid-drag would feel sticky.

Snap points
<Compare snapPoints={[0, 0.5, 1]} snapThreshold={0.08} onPositionChangeEnd={save}>
  …
</Compare>

Controlled

Controlled
const [position, setPosition] = useState(0.5);

<Compare position={position} onPositionChange={setPosition} onPositionChangeEnd={persist}>
  …
</Compare>

onPositionChange follows the drag at percent resolution. onPositionChangeEnd fires once, after the snap — that is where a network write belongs. A parent that refuses a value leaves position unchanged and the seam springs back to it.

Inside a scroll view

With the default grab="handle", only the handle's 44pt band claims a touch. A vertical flick anywhere else on the frame scrolls the list.

grab="anywhere" lets a drag start anywhere on the frame and a tap jump the seam. Activation is axis-aware: a drag along the seam's axis takes the touch, a flick across it goes to the scroll view.

<ScrollView delaysContentTouches={false}>
  <Compare grab="anywhere" accessibilityLabel="Exposure" className="aspect-[4/3]">…</Compare>
</ScrollView>

On iOS the handle feels late until the scrollable sets delaysContentTouches={false}. That is a prop at your call site, not something the component can set.

Accessibility

  • The handle is an adjustable — the frame is not, so each layer's content stays reachable on its own. Label your images.
  • Its value reads the Before layer's share: "50 percent before", or "Showing only after" at an end. A plain-string Compare.Label substitutes its text — "50 percent Original" — and is itself hidden from screen readers, so the name is heard once.
  • A VoiceOver or TalkBack swipe steps the seam by step (default 0.1), through the same snap and bounds as a drag, and fires onPositionChangeEnd.
  • Name it with accessibilityLabel, or wrap it in a Field and the Field.Label names it. With neither, a development warning fires.
  • A layer the seam covers completely leaves the accessibility tree, so nothing invisible is focusable.
  • Under reduce motion the release does not spring; the drag itself still follows the finger.

API Reference

Anatomy

PartWhat it is
CompareThe frame. Owns the position, the gesture and the axes
Compare.BeforeThe front layer, on the leading side, revealed by a sliding mask
Compare.AfterThe back layer: the full frame, never moved
Compare.HandleThe divider, the grip and the whole accessibility surface
Compare.LabelA caption pinned to one side, fading as that side is covered

Compare

Prop

Type

Compare.Before / Compare.After

Prop

Type

Compare.Handle

Prop

Type

Compare.Label

Prop

Type

useCompare

Reads the enclosing comparison's state — the live position shared value, the frame size and the axes — for a custom handle or overlay that has to follow the seam. Throws outside a Compare.

On this page