Split Pane
Two panes with a handle between them: drag to trade space, let go and it springs to the nearest stop.
import { useEffect, useState, type ReactNode } from 'react';
import {
StyleSheet,
View,
type AccessibilityActionEvent,
type StyleProp,
type ViewStyle,
} from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, { useAnimatedStyle, useSharedValue, withSpring } 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 { spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { minTouchTarget } from '../../lib/kinetik/tokens/tokens';
export type SplitPaneProps = {
/** The top pane (or the left one when horizontal). */
first: ReactNode;
second: ReactNode;
/** "vertical" stacks the panes; "horizontal" puts them side by side. Defaults to "vertical". */
direction?: 'vertical' | 'horizontal';
/** Share of the space the first pane starts with. Defaults to 0.5. */
initial?: number;
/** Shares the divider settles on when let go. Defaults to [0.25, 0.5, 0.75]. */
snapPoints?: readonly number[];
/** No pane gets smaller than this many points. Defaults to 56. */
minSize?: number;
onChange?: (share: number) => void;
/** A tick when the divider settles. Defaults to true. */
haptics?: boolean;
/** Names the divider for screen readers. Defaults to "Resize panes". */
accessibilityLabel?: string;
testID?: string;
/** Needs a fixed height when vertical (or width when horizontal). */
style?: StyleProp<ViewStyle>;
};
const GAP = 12;
/**
* Two panes with a divider between them. Drag the handle and the panes trade
* space under your finger; let go and the divider springs to the nearest
* resting point, taking the flick into account. The handle grows while held.
*/
export function SplitPane({
first,
second,
direction = 'vertical',
initial = 0.5,
snapPoints = [0.25, 0.5, 0.75],
minSize = 56,
onChange,
haptics = true,
accessibilityLabel = 'Resize panes',
testID,
style,
}: SplitPaneProps) {
const { colors, radius } = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const vertical = direction === 'vertical';
const [size, setSize] = useState(0);
const [share, setShare] = useState(initial);
const total = useSharedValue(0);
const at = useSharedValue(initial);
const start = useSharedValue(initial);
const held = useSharedValue(0);
const points = [...snapPoints].sort((a, b) => a - b);
useEffect(() => {
total.set(Math.max(0, size - GAP));
}, [size, total]);
const settle = (next: number) => {
setShare(next);
haptic('tick');
onChange?.(next);
};
const pan = Gesture.Pan()
.withTestId('split-pane')
.minDistance(0)
.onBegin(() => {
start.set(at.get());
held.set(withSpring(1, spring('snappy', reduced)));
})
.onUpdate((e) => {
const t = total.get();
if (t === 0) return;
const lo = Math.min(0.5, minSize / t);
const d = (vertical ? e.translationY : e.translationX) / t;
at.set(Math.max(lo, Math.min(1 - lo, start.get() + d)));
})
.onFinalize((e) => {
held.set(withSpring(0, spring('smooth', reduced)));
const t = total.get();
if (t === 0) return;
// Where a flick would carry it, then the nearest resting point.
const v = (vertical ? e.velocityY : e.velocityX) / t;
const aim = at.get() + v * 0.12;
let best = points[0] ?? 0.5;
for (const p of points) if (Math.abs(p - aim) < Math.abs(best - aim)) best = p;
at.set(reduced ? best : withSpring(best, spring('smooth', false, v)));
scheduleOnRN(settle, best);
});
const nudge = (e: AccessibilityActionEvent) => {
const up = e.nativeEvent.actionName === 'increment';
const next = up
? points.find((p) => p > share + 0.001)
: [...points].reverse().find((p) => p < share - 0.001);
if (next === undefined) return;
at.set(reduced ? next : withSpring(next, spring('smooth')));
settle(next);
};
const firstStyle = useAnimatedStyle(() => {
const s = at.get() * total.get();
return vertical ? { height: s } : { width: s };
});
const handle = useAnimatedStyle(() => {
const k = held.get();
return vertical
? { width: 36 + k * 20, opacity: 0.6 + k * 0.4 }
: { height: 36 + k * 20, opacity: 0.6 + k * 0.4 };
});
return (
<View
testID={testID}
style={[vertical ? styles.column : styles.row, style]}
onLayout={(e) => setSize(vertical ? e.nativeEvent.layout.height : e.nativeEvent.layout.width)}
>
<Animated.View style={[styles.pane, firstStyle]}>{first}</Animated.View>
<GestureDetector gesture={pan}>
<View
collapsable={false}
accessible
accessibilityRole="adjustable"
accessibilityLabel={accessibilityLabel}
accessibilityValue={{ text: `${Math.round(share * 100)} percent` }}
accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
onAccessibilityAction={nudge}
style={vertical ? styles.dividerY : styles.dividerX}
>
<Animated.View
style={[
vertical ? styles.handleY : styles.handleX,
{ backgroundColor: colors.borderStrong, borderRadius: radius.pill },
handle,
]}
/>
</View>
</GestureDetector>
<View style={[styles.pane, styles.fill]}>{second}</View>
</View>
);
}
const styles = StyleSheet.create({
column: { flexDirection: 'column' },
row: { flexDirection: 'row' },
pane: { overflow: 'hidden' },
fill: { flex: 1 },
// The divider is a full 44 pt target that overlaps the panes, but only takes GAP of space.
dividerY: {
height: minTouchTarget,
marginVertical: -(minTouchTarget - GAP) / 2,
alignItems: 'center',
justifyContent: 'center',
zIndex: 1,
},
dividerX: {
width: minTouchTarget,
marginHorizontal: -(minTouchTarget - GAP) / 2,
alignItems: 'center',
justifyContent: 'center',
zIndex: 1,
},
handleY: { height: 4 },
handleX: { width: 4 },
});Installation
npx kinetik-ui add split-panepnpm dlx kinetik-ui add split-panebunx kinetik-ui add split-paneyarn dlx kinetik-ui add split-paneThe 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/split-pane.json
Install manually
- Install dependencies
npx expo install react-native-gesture-handler react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/split-pane.tsx
Usage
import { SplitPane } from '@/components/kinetik/split-pane';
<SplitPane style={{ height: 520 }} first={<Map />} second={<PlaceList />} />;When to use
- Two related views people balance against each other: a map and a list, a preview and its settings, a chat and a document.
- Tablet layouts side by side with
direction="horizontal".
When not to use
- A sheet that slides over content. Use Morph Sheet or Action Sheet.
- Content people only switch between. Use Scroll Tabs.
Notes
- The divider follows the finger, then springs to the nearest of
snapPoints, carrying the flick's speed into the spring. minSizekeeps either pane from vanishing. Give the component a fixed height (or width when horizontal).- The divider is an adjustable element: screen readers step it between the resting points.
- It is a 44 pt target that overlaps the panes, while only 12 pt of space separates them.
Props
SplitPaneProps
| Prop | Type | Default | Description |
|---|---|---|---|
firstrequired | ReactNode | — | The top pane (or the left one when horizontal). |
secondrequired | ReactNode | — | |
direction | 'vertical' | 'horizontal' | 'vertical' | "vertical" stacks the panes; "horizontal" puts them side by side. Defaults to "vertical". |
initial | number | 0.5 | Share of the space the first pane starts with. Defaults to 0.5. |
snapPoints | readonly number[] | [0.25, 0.5, 0.75] | Shares the divider settles on when let go. Defaults to [0.25, 0.5, 0.75]. |
minSize | number | 56 | No pane gets smaller than this many points. Defaults to 56. |
onChange | (share: number) => void | — | |
haptics | boolean | true | A tick when the divider settles. Defaults to true. |
accessibilityLabel | string | 'Resize panes' | Names the divider for screen readers. Defaults to "Resize panes". |
testID | string | — | |
style | StyleProp<ViewStyle> | — | Needs a fixed height when vertical (or width when horizontal). |
Performance and accessibility
Performance
- Budget
- Plain views; the first pane's size and the handle read two shared values, so dragging lays out only the two panes. Nothing runs at rest.
- Measured
Pixel 9 emulator, API 35, release build · interaction median 0.94× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 142 MB app memory
Real-device measurement pending.
Accessibility
- Reduced motion
- The divider jumps to its resting point and the handle does not grow.
- Screen readers
- The divider is an adjustable element with the first pane's share as a percentage, stepping between resting points.
- Touch target
- The divider is a full-width target 44 pt tall.
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/split-pane.tsx
import { useEffect, useState, type ReactNode } from 'react';
import {
StyleSheet,
View,
type AccessibilityActionEvent,
type StyleProp,
type ViewStyle,
} from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, { useAnimatedStyle, useSharedValue, withSpring } 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 { spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { minTouchTarget } from '../../lib/kinetik/tokens/tokens';
export type SplitPaneProps = {
/** The top pane (or the left one when horizontal). */
first: ReactNode;
second: ReactNode;
/** "vertical" stacks the panes; "horizontal" puts them side by side. Defaults to "vertical". */
direction?: 'vertical' | 'horizontal';
/** Share of the space the first pane starts with. Defaults to 0.5. */
initial?: number;
/** Shares the divider settles on when let go. Defaults to [0.25, 0.5, 0.75]. */
snapPoints?: readonly number[];
/** No pane gets smaller than this many points. Defaults to 56. */
minSize?: number;
onChange?: (share: number) => void;
/** A tick when the divider settles. Defaults to true. */
haptics?: boolean;
/** Names the divider for screen readers. Defaults to "Resize panes". */
accessibilityLabel?: string;
testID?: string;
/** Needs a fixed height when vertical (or width when horizontal). */
style?: StyleProp<ViewStyle>;
};
const GAP = 12;
/**
* Two panes with a divider between them. Drag the handle and the panes trade
* space under your finger; let go and the divider springs to the nearest
* resting point, taking the flick into account. The handle grows while held.
*/
export function SplitPane({
first,
second,
direction = 'vertical',
initial = 0.5,
snapPoints = [0.25, 0.5, 0.75],
minSize = 56,
onChange,
haptics = true,
accessibilityLabel = 'Resize panes',
testID,
style,
}: SplitPaneProps) {
const { colors, radius } = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const vertical = direction === 'vertical';
const [size, setSize] = useState(0);
const [share, setShare] = useState(initial);
const total = useSharedValue(0);
const at = useSharedValue(initial);
const start = useSharedValue(initial);
const held = useSharedValue(0);
const points = [...snapPoints].sort((a, b) => a - b);
useEffect(() => {
total.set(Math.max(0, size - GAP));
}, [size, total]);
const settle = (next: number) => {
setShare(next);
haptic('tick');
onChange?.(next);
};
const pan = Gesture.Pan()
.withTestId('split-pane')
.minDistance(0)
.onBegin(() => {
start.set(at.get());
held.set(withSpring(1, spring('snappy', reduced)));
})
.onUpdate((e) => {
const t = total.get();
if (t === 0) return;
const lo = Math.min(0.5, minSize / t);
const d = (vertical ? e.translationY : e.translationX) / t;
at.set(Math.max(lo, Math.min(1 - lo, start.get() + d)));
})
.onFinalize((e) => {
held.set(withSpring(0, spring('smooth', reduced)));
const t = total.get();
if (t === 0) return;
// Where a flick would carry it, then the nearest resting point.
const v = (vertical ? e.velocityY : e.velocityX) / t;
const aim = at.get() + v * 0.12;
let best = points[0] ?? 0.5;
for (const p of points) if (Math.abs(p - aim) < Math.abs(best - aim)) best = p;
at.set(reduced ? best : withSpring(best, spring('smooth', false, v)));
scheduleOnRN(settle, best);
});
const nudge = (e: AccessibilityActionEvent) => {
const up = e.nativeEvent.actionName === 'increment';
const next = up
? points.find((p) => p > share + 0.001)
: [...points].reverse().find((p) => p < share - 0.001);
if (next === undefined) return;
at.set(reduced ? next : withSpring(next, spring('smooth')));
settle(next);
};
const firstStyle = useAnimatedStyle(() => {
const s = at.get() * total.get();
return vertical ? { height: s } : { width: s };
});
const handle = useAnimatedStyle(() => {
const k = held.get();
return vertical
? { width: 36 + k * 20, opacity: 0.6 + k * 0.4 }
: { height: 36 + k * 20, opacity: 0.6 + k * 0.4 };
});
return (
<View
testID={testID}
style={[vertical ? styles.column : styles.row, style]}
onLayout={(e) => setSize(vertical ? e.nativeEvent.layout.height : e.nativeEvent.layout.width)}
>
<Animated.View style={[styles.pane, firstStyle]}>{first}</Animated.View>
<GestureDetector gesture={pan}>
<View
collapsable={false}
accessible
accessibilityRole="adjustable"
accessibilityLabel={accessibilityLabel}
accessibilityValue={{ text: `${Math.round(share * 100)} percent` }}
accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
onAccessibilityAction={nudge}
style={vertical ? styles.dividerY : styles.dividerX}
>
<Animated.View
style={[
vertical ? styles.handleY : styles.handleX,
{ backgroundColor: colors.borderStrong, borderRadius: radius.pill },
handle,
]}
/>
</View>
</GestureDetector>
<View style={[styles.pane, styles.fill]}>{second}</View>
</View>
);
}
const styles = StyleSheet.create({
column: { flexDirection: 'column' },
row: { flexDirection: 'row' },
pane: { overflow: 'hidden' },
fill: { flex: 1 },
// The divider is a full 44 pt target that overlaps the panes, but only takes GAP of space.
dividerY: {
height: minTouchTarget,
marginVertical: -(minTouchTarget - GAP) / 2,
alignItems: 'center',
justifyContent: 'center',
zIndex: 1,
},
dividerX: {
width: minTouchTarget,
marginHorizontal: -(minTouchTarget - GAP) / 2,
alignItems: 'center',
justifyContent: 'center',
zIndex: 1,
},
handleY: { height: 4 },
handleX: { width: 4 },
});