Kinetik UI

Compare Slider

.md

Two versions of a picture with a divider you drag to reveal either side; it nudges once to show it moves.

v1.0.0Live on webreact-native-gesture-handlerreact-native-reanimatedreact-native-worklets
Component preview

Installation

Kinetik CLI
npx kinetik-ui add compare-slider

The 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

  1. Install dependencies
    npx expo install react-native-gesture-handler react-native-reanimated react-native-worklets
  2. Copy the source files
    • components/kinetik/compare-slider.tsx
    View source files

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 before picture 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

PropTypeDefaultDescription
beforerequiredReactNode—Shown left of the divider, e.g. the original photo. Fills the frame.
afterrequiredReactNode—Shown right of the divider, e.g. the edited photo. Fills the frame.
accessibilityLabelrequiredstring—Names the comparison, e.g. "Colour grade".
initialnumber0.5Where the divider starts, 0 to 1. Defaults to 0.5.
beforeLabelstring'Before'Defaults to 'Before'.
afterLabelstring'After'Defaults to 'After'.
aspectRationumber4 / 3Width over height. Defaults to 4 / 3.
hintbooleantrueNudge 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.
hapticsbooleantrueA tick at either end. Defaults to true.
testIDstring—
styleStyleProp<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' }] },
});