Compare Slider
Two versions of a picture with a divider you drag to reveal either side; it nudges once to show it moves.
import { useEffect, useState, type ReactNode } from 'react';
import {
StyleSheet,
Text,
View,
type AccessibilityActionEvent,
type StyleProp,
type ViewStyle,
} from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
useAnimatedStyle,
useSharedValue,
withDelay,
withSequence,
withSpring,
withTiming,
} from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';
import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { easings, spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { withAlpha } from '../../lib/kinetik/tokens/tokens';
export type CompareSliderProps = {
/** Shown left of the divider, e.g. the original photo. Fills the frame. */
before: ReactNode;
/** Shown right of the divider, e.g. the edited photo. Fills the frame. */
after: ReactNode;
/** Where the divider starts, 0 to 1. Defaults to 0.5. */
initial?: number;
/** Defaults to 'Before'. */
beforeLabel?: string;
/** Defaults to 'After'. */
afterLabel?: string;
/** Width over height. Defaults to 4 / 3. */
aspectRatio?: number;
/** Nudge the divider once on mount so people see it moves. Defaults to true. */
hint?: boolean;
/** Called when the finger lifts, with the divider's position, 0 to 1. */
onPositionChange?: (position: number) => void;
/** Names the comparison, e.g. "Colour grade". */
accessibilityLabel: string;
/** A tick at either end. Defaults to true. */
haptics?: boolean;
testID?: string;
style?: StyleProp<ViewStyle>;
};
const KNOB = 40;
const LINE = 2;
/**
* Two versions of a picture, one over the other, with a divider you drag
* across to reveal either side. The labels step aside as the divider nears
* them, and on first sight it nudges once to show that it moves.
*/
export function CompareSlider({
before,
after,
initial = 0.5,
beforeLabel = 'Before',
afterLabel = 'After',
aspectRatio = 4 / 3,
hint = true,
onPositionChange,
accessibilityLabel,
haptics = true,
testID,
style,
}: CompareSliderProps) {
const { colors, font, radius, shadow } = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const [width, setWidth] = useState(0);
const [shown, setShown] = useState(Math.round(initial * 100));
const pos = useSharedValue(initial);
const held = useSharedValue(0);
const atEdge = useSharedValue(0);
useEffect(() => {
if (!hint || reduced || width === 0) return;
pos.set(
withDelay(
500,
withSequence(
withTiming(Math.min(0.92, initial + 0.12), { duration: 420, easing: easings.inOut }),
withSpring(initial, spring('smooth')),
),
),
);
}, [hint, reduced, width, initial, pos]);
const report = (p: number) => {
setShown(Math.round(p * 100));
onPositionChange?.(p);
};
const tick = () => haptic('tick');
const moveTo = (x: number) => {
'worklet';
if (width === 0) return;
const p = Math.max(0, Math.min(1, x / width));
pos.set(p);
const edge = p === 0 || p === 1 ? 1 : 0;
if (edge !== atEdge.get()) {
atEdge.set(edge);
if (edge) scheduleOnRN(tick);
}
};
const pan = Gesture.Pan()
.withTestId('compare-slider')
.activeOffsetX([-6, 6])
.failOffsetY([-12, 12])
.onBegin(() => held.set(withSpring(1, spring('snappy', reduced))))
.onUpdate((e) => moveTo(e.x))
.onFinalize(() => {
held.set(withSpring(0, spring('smooth', reduced)));
scheduleOnRN(report, pos.get());
});
const tap = Gesture.Tap()
.withTestId('compare-slider-tap')
.onEnd((e) => {
if (width === 0) return;
const p = Math.max(0, Math.min(1, e.x / width));
pos.set(reduced ? p : withSpring(p, spring('smooth')));
scheduleOnRN(report, p);
});
const clip = useAnimatedStyle(() => ({ width: pos.get() * width }));
const divider = useAnimatedStyle(() => ({
transform: [{ translateX: pos.get() * width - LINE / 2 }],
}));
const knob = useAnimatedStyle(() => ({ transform: [{ scale: 1 + held.get() * 0.12 }] }));
// A label fades as the divider comes within a fifth of the width of it.
const leftTag = useAnimatedStyle(() => ({
opacity: Math.max(0, Math.min(1, (pos.get() - 0.12) / 0.2)),
}));
const rightTag = useAnimatedStyle(() => ({
opacity: Math.max(0, Math.min(1, (0.88 - pos.get()) / 0.2)),
}));
const onAccessibilityAction = (e: AccessibilityActionEvent) => {
const step = e.nativeEvent.actionName === 'increment' ? 0.1 : -0.1;
const p = Math.max(0, Math.min(1, Math.round((pos.get() + step) * 10) / 10));
pos.set(reduced ? p : withSpring(p, spring('smooth')));
report(p);
};
const tag = (text: string) => (
<View
style={[
styles.tag,
{ backgroundColor: withAlpha(colors.background, 0.55), borderRadius: radius.pill },
]}
>
<Text
style={{ color: colors.text, fontSize: font.size.xs, fontWeight: font.weight.semibold }}
>
{text}
</Text>
</View>
);
return (
<GestureDetector gesture={Gesture.Race(pan, tap)}>
<View
collapsable={false}
testID={testID}
accessible
accessibilityRole="adjustable"
accessibilityLabel={accessibilityLabel}
accessibilityHint={`Shows more of ${beforeLabel} or ${afterLabel}`}
accessibilityValue={{
min: 0,
max: 100,
now: shown,
text: `${shown}% ${beforeLabel.toLowerCase()}`,
}}
accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
onAccessibilityAction={onAccessibilityAction}
onLayout={(e) => setWidth(e.nativeEvent.layout.width)}
style={[
styles.frame,
{ aspectRatio, borderRadius: radius.xl, backgroundColor: colors.surfaceSunken },
style,
]}
>
<View style={StyleSheet.absoluteFill} pointerEvents="none">
{after}
</View>
<Animated.View style={[styles.clip, clip]} pointerEvents="none">
{/* Full width inside the clip, so the picture is cut, never squeezed. */}
<View style={[styles.full, { width }]}>{before}</View>
</Animated.View>
<Animated.View style={[styles.tagLeft, leftTag]} pointerEvents="none">
{tag(beforeLabel)}
</Animated.View>
<Animated.View style={[styles.tagRight, rightTag]} pointerEvents="none">
{tag(afterLabel)}
</Animated.View>
<Animated.View
pointerEvents="none"
style={[styles.divider, { backgroundColor: colors.ember }, divider]}
>
<Animated.View
style={[styles.knob, { backgroundColor: colors.ember, boxShadow: shadow.md }, knob]}
>
<View style={[styles.arrow, styles.arrowLeft, { borderColor: colors.onEmber }]} />
<View style={[styles.arrow, styles.arrowRight, { borderColor: colors.onEmber }]} />
</Animated.View>
</Animated.View>
</View>
</GestureDetector>
);
}
const styles = StyleSheet.create({
frame: { width: '100%', overflow: 'hidden' },
clip: { position: 'absolute', left: 0, top: 0, bottom: 0, overflow: 'hidden' },
full: { position: 'absolute', left: 0, top: 0, bottom: 0 },
tag: { paddingHorizontal: 10, paddingVertical: 4 },
tagLeft: { position: 'absolute', left: 12, top: 12 },
tagRight: { position: 'absolute', right: 12, top: 12 },
divider: {
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
width: LINE,
alignItems: 'center',
justifyContent: 'center',
},
knob: {
width: KNOB,
height: KNOB,
borderRadius: KNOB / 2,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
},
arrow: { width: 7, height: 7, borderTopWidth: 2, borderLeftWidth: 2 },
arrowLeft: { transform: [{ rotate: '-45deg' }] },
arrowRight: { transform: [{ rotate: '135deg' }] },
});Installation
npx kinetik-ui add compare-sliderpnpm dlx kinetik-ui add compare-sliderbunx kinetik-ui add compare-slideryarn dlx kinetik-ui add compare-sliderThe CLI copies the source and installs native dependencies with npx expo install, so versions match your Expo SDK. With shadcn: npx shadcn@latest add https://kinetik-ui.dev/r/compare-slider.json
Install manually
- Install dependencies
npx expo install react-native-gesture-handler react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/compare-slider.tsx
Usage
import { CompareSlider } from '@/components/kinetik/compare-slider';
<CompareSlider
accessibilityLabel="Colour grade"
beforeLabel="Original"
afterLabel="Graded"
before={<Image source={original} style={StyleSheet.absoluteFill} />}
after={<Image source={graded} style={StyleSheet.absoluteFill} />}
/>;before and after fill the frame, one over the other. The divider shows before to its left and after to its right.
When to use
- Showing what an edit, a filter or a restoration changed.
- Before and after for renovations, cleaning, retouching, maps across years.
When not to use
- Pictures that do not line up. Show them side by side.
- More than two versions. Use a carousel.
Notes
- The frame takes the width it is given and sets its height with
aspectRatio(4:3 by default). Make both pictures fill it. - The
beforepicture is cut by the divider, never squeezed. - Drag anywhere on the picture, or tap to jump the divider there. Vertical drags still scroll the page.
- On first sight the divider nudges once to show that it moves; turn this off with
hint={false}. - With reduced motion there is no nudge, and jumps happen without a spring.
Props
CompareSliderProps
| Prop | Type | Default | Description |
|---|---|---|---|
beforerequired | ReactNode | — | Shown left of the divider, e.g. the original photo. Fills the frame. |
afterrequired | ReactNode | — | Shown right of the divider, e.g. the edited photo. Fills the frame. |
accessibilityLabelrequired | string | — | Names the comparison, e.g. "Colour grade". |
initial | number | 0.5 | Where the divider starts, 0 to 1. Defaults to 0.5. |
beforeLabel | string | 'Before' | Defaults to 'Before'. |
afterLabel | string | 'After' | Defaults to 'After'. |
aspectRatio | number | 4 / 3 | Width over height. Defaults to 4 / 3. |
hint | boolean | true | Nudge the divider once on mount so people see it moves. Defaults to true. |
onPositionChange | (position: number) => void | — | Called when the finger lifts, with the divider's position, 0 to 1. |
haptics | boolean | true | A tick at either end. Defaults to true. |
testID | string | — | |
style | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- The divider and the clip move on the UI thread from one shared value; the pictures themselves never re-render. Nothing runs at rest.
- Measured
Pixel 9 emulator, API 35, release build · interaction median 1.05× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 151 MB app memory
Real-device measurement pending.
Accessibility
- Reduced motion
- No nudge on mount, and taps and screen reader steps move the divider without a spring.
- Screen readers
- One adjustable element named by accessibilityLabel; increment and decrement move the divider by a tenth and read how much of the first version shows.
- Touch target
- The whole picture takes the drag and taps.
Try it on a device
Scan with a phone that has the Kinetik playground installed to open this demo with real haptics and sensors.
Source files
components/kinetik/compare-slider.tsx
import { useEffect, useState, type ReactNode } from 'react';
import {
StyleSheet,
Text,
View,
type AccessibilityActionEvent,
type StyleProp,
type ViewStyle,
} from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
useAnimatedStyle,
useSharedValue,
withDelay,
withSequence,
withSpring,
withTiming,
} from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';
import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { easings, spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { withAlpha } from '../../lib/kinetik/tokens/tokens';
export type CompareSliderProps = {
/** Shown left of the divider, e.g. the original photo. Fills the frame. */
before: ReactNode;
/** Shown right of the divider, e.g. the edited photo. Fills the frame. */
after: ReactNode;
/** Where the divider starts, 0 to 1. Defaults to 0.5. */
initial?: number;
/** Defaults to 'Before'. */
beforeLabel?: string;
/** Defaults to 'After'. */
afterLabel?: string;
/** Width over height. Defaults to 4 / 3. */
aspectRatio?: number;
/** Nudge the divider once on mount so people see it moves. Defaults to true. */
hint?: boolean;
/** Called when the finger lifts, with the divider's position, 0 to 1. */
onPositionChange?: (position: number) => void;
/** Names the comparison, e.g. "Colour grade". */
accessibilityLabel: string;
/** A tick at either end. Defaults to true. */
haptics?: boolean;
testID?: string;
style?: StyleProp<ViewStyle>;
};
const KNOB = 40;
const LINE = 2;
/**
* Two versions of a picture, one over the other, with a divider you drag
* across to reveal either side. The labels step aside as the divider nears
* them, and on first sight it nudges once to show that it moves.
*/
export function CompareSlider({
before,
after,
initial = 0.5,
beforeLabel = 'Before',
afterLabel = 'After',
aspectRatio = 4 / 3,
hint = true,
onPositionChange,
accessibilityLabel,
haptics = true,
testID,
style,
}: CompareSliderProps) {
const { colors, font, radius, shadow } = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const [width, setWidth] = useState(0);
const [shown, setShown] = useState(Math.round(initial * 100));
const pos = useSharedValue(initial);
const held = useSharedValue(0);
const atEdge = useSharedValue(0);
useEffect(() => {
if (!hint || reduced || width === 0) return;
pos.set(
withDelay(
500,
withSequence(
withTiming(Math.min(0.92, initial + 0.12), { duration: 420, easing: easings.inOut }),
withSpring(initial, spring('smooth')),
),
),
);
}, [hint, reduced, width, initial, pos]);
const report = (p: number) => {
setShown(Math.round(p * 100));
onPositionChange?.(p);
};
const tick = () => haptic('tick');
const moveTo = (x: number) => {
'worklet';
if (width === 0) return;
const p = Math.max(0, Math.min(1, x / width));
pos.set(p);
const edge = p === 0 || p === 1 ? 1 : 0;
if (edge !== atEdge.get()) {
atEdge.set(edge);
if (edge) scheduleOnRN(tick);
}
};
const pan = Gesture.Pan()
.withTestId('compare-slider')
.activeOffsetX([-6, 6])
.failOffsetY([-12, 12])
.onBegin(() => held.set(withSpring(1, spring('snappy', reduced))))
.onUpdate((e) => moveTo(e.x))
.onFinalize(() => {
held.set(withSpring(0, spring('smooth', reduced)));
scheduleOnRN(report, pos.get());
});
const tap = Gesture.Tap()
.withTestId('compare-slider-tap')
.onEnd((e) => {
if (width === 0) return;
const p = Math.max(0, Math.min(1, e.x / width));
pos.set(reduced ? p : withSpring(p, spring('smooth')));
scheduleOnRN(report, p);
});
const clip = useAnimatedStyle(() => ({ width: pos.get() * width }));
const divider = useAnimatedStyle(() => ({
transform: [{ translateX: pos.get() * width - LINE / 2 }],
}));
const knob = useAnimatedStyle(() => ({ transform: [{ scale: 1 + held.get() * 0.12 }] }));
// A label fades as the divider comes within a fifth of the width of it.
const leftTag = useAnimatedStyle(() => ({
opacity: Math.max(0, Math.min(1, (pos.get() - 0.12) / 0.2)),
}));
const rightTag = useAnimatedStyle(() => ({
opacity: Math.max(0, Math.min(1, (0.88 - pos.get()) / 0.2)),
}));
const onAccessibilityAction = (e: AccessibilityActionEvent) => {
const step = e.nativeEvent.actionName === 'increment' ? 0.1 : -0.1;
const p = Math.max(0, Math.min(1, Math.round((pos.get() + step) * 10) / 10));
pos.set(reduced ? p : withSpring(p, spring('smooth')));
report(p);
};
const tag = (text: string) => (
<View
style={[
styles.tag,
{ backgroundColor: withAlpha(colors.background, 0.55), borderRadius: radius.pill },
]}
>
<Text
style={{ color: colors.text, fontSize: font.size.xs, fontWeight: font.weight.semibold }}
>
{text}
</Text>
</View>
);
return (
<GestureDetector gesture={Gesture.Race(pan, tap)}>
<View
collapsable={false}
testID={testID}
accessible
accessibilityRole="adjustable"
accessibilityLabel={accessibilityLabel}
accessibilityHint={`Shows more of ${beforeLabel} or ${afterLabel}`}
accessibilityValue={{
min: 0,
max: 100,
now: shown,
text: `${shown}% ${beforeLabel.toLowerCase()}`,
}}
accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
onAccessibilityAction={onAccessibilityAction}
onLayout={(e) => setWidth(e.nativeEvent.layout.width)}
style={[
styles.frame,
{ aspectRatio, borderRadius: radius.xl, backgroundColor: colors.surfaceSunken },
style,
]}
>
<View style={StyleSheet.absoluteFill} pointerEvents="none">
{after}
</View>
<Animated.View style={[styles.clip, clip]} pointerEvents="none">
{/* Full width inside the clip, so the picture is cut, never squeezed. */}
<View style={[styles.full, { width }]}>{before}</View>
</Animated.View>
<Animated.View style={[styles.tagLeft, leftTag]} pointerEvents="none">
{tag(beforeLabel)}
</Animated.View>
<Animated.View style={[styles.tagRight, rightTag]} pointerEvents="none">
{tag(afterLabel)}
</Animated.View>
<Animated.View
pointerEvents="none"
style={[styles.divider, { backgroundColor: colors.ember }, divider]}
>
<Animated.View
style={[styles.knob, { backgroundColor: colors.ember, boxShadow: shadow.md }, knob]}
>
<View style={[styles.arrow, styles.arrowLeft, { borderColor: colors.onEmber }]} />
<View style={[styles.arrow, styles.arrowRight, { borderColor: colors.onEmber }]} />
</Animated.View>
</Animated.View>
</View>
</GestureDetector>
);
}
const styles = StyleSheet.create({
frame: { width: '100%', overflow: 'hidden' },
clip: { position: 'absolute', left: 0, top: 0, bottom: 0, overflow: 'hidden' },
full: { position: 'absolute', left: 0, top: 0, bottom: 0 },
tag: { paddingHorizontal: 10, paddingVertical: 4 },
tagLeft: { position: 'absolute', left: 12, top: 12 },
tagRight: { position: 'absolute', right: 12, top: 12 },
divider: {
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
width: LINE,
alignItems: 'center',
justifyContent: 'center',
},
knob: {
width: KNOB,
height: KNOB,
borderRadius: KNOB / 2,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
},
arrow: { width: 7, height: 7, borderTopWidth: 2, borderLeftWidth: 2 },
arrowLeft: { transform: [{ rotate: '-45deg' }] },
arrowRight: { transform: [{ rotate: '135deg' }] },
});