Pager
Pagination with a pill that glides to the page; the row keeps its width while the numbers roll in place.
import { useEffect, useState } from 'react';
import { Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
} from 'react-native-reanimated';
import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { durations, easings, spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { minTouchTarget } from '../../lib/kinetik/tokens/tokens';
export type PagerProps = {
/** The current page, from 1. */
page: number;
pageCount: number;
onPageChange: (page: number) => void;
/** Pages shown on each side of the current one, between the gaps. Defaults to 0. */
siblings?: number;
/** Names the control. Defaults to "Pages". */
accessibilityLabel?: string;
/** A tick on each change. Defaults to true. */
haptics?: boolean;
testID?: string;
style?: StyleProp<ViewStyle>;
};
export type PagerSlot = number | 'gap';
const CELL = minTouchTarget;
/**
* The slots to show: the first and last page, the current one with its
* siblings, and a gap where pages are skipped. Past a few pages the number of
* slots stays the same, so the control never changes width.
*/
export function pagerSlots(page: number, count: number, siblings = 0): PagerSlot[] {
const slots = siblings * 2 + 5;
if (count <= slots) return Array.from({ length: count }, (_, i) => i + 1);
const left = Math.max(page - siblings, 1);
const right = Math.min(page + siblings, count);
const leftGap = left > 3;
const rightGap = right < count - 2;
const span = 3 + siblings * 2;
if (!leftGap && rightGap) {
return [...Array.from({ length: span }, (_, i) => i + 1), 'gap', count];
}
if (leftGap && !rightGap) {
return [1, 'gap', ...Array.from({ length: span }, (_, i) => count - span + i + 1)];
}
return [1, 'gap', ...Array.from({ length: right - left + 1 }, (_, i) => left + i), 'gap', count];
}
/**
* Pagination with a pill that glides to the current page. The row keeps its
* width: when the window of numbers moves, the digits roll to their new
* values in place instead of the buttons shifting about.
*/
export function Pager({
page,
pageCount,
onPageChange,
siblings = 0,
accessibilityLabel = 'Pages',
haptics = true,
testID,
style,
}: PagerProps) {
const { colors, radius } = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const count = Math.max(1, pageCount);
const current = Math.min(Math.max(1, page), count);
const slots = pagerSlots(current, count, siblings);
const at = slots.indexOf(current);
const x = useSharedValue(at * CELL);
useEffect(() => {
x.set(reduced ? at * CELL : withSpring(at * CELL, spring('snappy')));
}, [at, reduced, x]);
const pill = useAnimatedStyle(() => ({ transform: [{ translateX: x.get() }] }));
const go = (p: number) => {
const next = Math.min(Math.max(1, p), count);
if (next === current) return;
haptic('tick');
onPageChange(next);
};
return (
<View testID={testID} accessibilityLabel={accessibilityLabel} style={[styles.row, style]}>
<Arrow dir={-1} disabled={current === 1} onPress={() => go(current - 1)} />
<View style={styles.slots}>
<Animated.View
pointerEvents="none"
style={[styles.pill, { backgroundColor: colors.ember, borderRadius: radius.md }, pill]}
/>
{slots.map((slot, i) =>
slot === 'gap' ? (
<View
key={`gap-${i}`}
style={styles.cell}
accessibilityElementsHidden
importantForAccessibility="no"
>
<Text style={{ color: colors.textSubtle }}>…</Text>
</View>
) : (
<Slot
key={`slot-${i}`}
value={slot}
current={slot === current}
reduced={reduced}
onPress={() => go(slot)}
/>
),
)}
</View>
<Arrow dir={1} disabled={current === count} onPress={() => go(current + 1)} />
</View>
);
}
type SlotProps = { value: number; current: boolean; reduced: boolean; onPress: () => void };
/** One numbered button. When its number changes, the old one rolls out and the new one in. */
function Slot({ value, current, reduced, onPress }: SlotProps) {
const { colors, font } = useKinetikTheme();
const [roll, setRoll] = useState({ value, prev: value, dir: 1, n: 0 });
if (roll.value !== value) {
setRoll({ value, prev: roll.value, dir: value > roll.value ? 1 : -1, n: roll.n + 1 });
}
const p = useSharedValue(1);
useEffect(() => {
if (roll.n === 0 || reduced) return;
p.set(0);
p.set(withTiming(1, { duration: durations.base, easing: easings.standard }));
}, [roll.n, reduced, p]);
const incoming = useAnimatedStyle(() => ({
opacity: p.get(),
transform: [{ translateY: (1 - p.get()) * 10 * roll.dir }],
}));
const outgoing = useAnimatedStyle(() => ({
opacity: 1 - p.get(),
transform: [{ translateY: -p.get() * 10 * roll.dir }],
}));
const text = {
color: current ? colors.onEmber : colors.text,
fontSize: font.size.md,
fontWeight: current ? font.weight.semibold : font.weight.regular,
fontVariant: ['tabular-nums' as const],
};
return (
<Pressable
onPress={onPress}
accessibilityRole="button"
accessibilityLabel={`Page ${value}`}
accessibilityState={{ selected: current }}
style={styles.cell}
>
{roll.prev !== roll.value ? (
<Animated.Text style={[text, styles.ghost, outgoing]}>{roll.prev}</Animated.Text>
) : null}
<Animated.Text style={[text, incoming]}>{value}</Animated.Text>
</Pressable>
);
}
function Arrow({
dir,
disabled,
onPress,
}: {
dir: 1 | -1;
disabled: boolean;
onPress: () => void;
}) {
const { colors, radius } = useKinetikTheme();
return (
<Pressable
onPress={onPress}
disabled={disabled}
accessibilityRole="button"
accessibilityLabel={dir === 1 ? 'Next page' : 'Previous page'}
accessibilityState={{ disabled }}
style={({ pressed }) => [
styles.cell,
{
borderRadius: radius.md,
backgroundColor: pressed ? colors.surfaceSunken : undefined,
opacity: disabled ? 0.3 : 1,
},
]}
>
<View style={[styles.icon, { transform: [{ scaleX: dir }] }]}>
<View style={[styles.stroke, styles.up, { backgroundColor: colors.text }]} />
<View style={[styles.stroke, styles.down, { backgroundColor: colors.text }]} />
</View>
</Pressable>
);
}
const styles = StyleSheet.create({
row: { flexDirection: 'row', alignItems: 'center', alignSelf: 'center' },
slots: { flexDirection: 'row' },
pill: { position: 'absolute', left: 4, top: 4, width: CELL - 8, height: CELL - 8 },
cell: {
width: CELL,
height: CELL,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
ghost: { position: 'absolute' },
icon: { width: 14, height: 14 },
stroke: { position: 'absolute', width: 8, height: 1.8, borderRadius: 1, left: 4 },
up: { top: 4.2, transform: [{ rotate: '45deg' }] },
down: { top: 8.8, transform: [{ rotate: '-45deg' }] },
});Installation
npx kinetik-ui add pagerpnpm dlx kinetik-ui add pagerbunx kinetik-ui add pageryarn dlx kinetik-ui add pagerThe 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/pager.json
Install manually
- Install dependencies
npx expo install react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/pager.tsx
Usage
import { Pager } from '@/components/kinetik/pager';
<Pager page={page} pageCount={24} onPageChange={setPage} />;When to use
- Long lists split into pages people jump between: search results, orders, records.
- Places where knowing "page 9 of 24" matters more than endless scrolling.
When not to use
- Feeds people scroll through once. Load more as they reach the end.
- Swiping between a few full-screen slides. Use Page Dots.
Notes
- The first and last pages are always shown, with the current page between gaps; the number of slots never changes, so the row keeps its width.
siblingsadds pages either side of the current one; each slot is 44 pt, sosiblings={1}needs about 400 pt.pagerSlots(page, count, siblings)is exported if you want the same logic elsewhere.- With reduced motion the pill jumps and digits change in place.
Props
PagerProps
| Prop | Type | Default | Description |
|---|---|---|---|
pagerequired | number | — | The current page, from 1. |
pageCountrequired | number | — | |
onPageChangerequired | (page: number) => void | — | |
siblings | number | 0 | Pages shown on each side of the current one, between the gaps. Defaults to 0. |
accessibilityLabel | string | 'Pages' | Names the control. Defaults to "Pages". |
haptics | boolean | true | A tick on each change. Defaults to true. |
testID | string | — | |
style | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- Plain views; one pill on one shared value and one more per slot for the roll. 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 · 143 MB app memory
Real-device measurement pending.
Accessibility
- Reduced motion
- The pill jumps and the numbers change in place.
- Screen readers
- Buttons named "Page 9" with the current one selected, plus Previous and Next page buttons that are disabled at the ends.
- Touch target
- Every slot and arrow is 44 by 44 pt.
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/pager.tsx
import { useEffect, useState } from 'react';
import { Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
} from 'react-native-reanimated';
import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { durations, easings, spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { minTouchTarget } from '../../lib/kinetik/tokens/tokens';
export type PagerProps = {
/** The current page, from 1. */
page: number;
pageCount: number;
onPageChange: (page: number) => void;
/** Pages shown on each side of the current one, between the gaps. Defaults to 0. */
siblings?: number;
/** Names the control. Defaults to "Pages". */
accessibilityLabel?: string;
/** A tick on each change. Defaults to true. */
haptics?: boolean;
testID?: string;
style?: StyleProp<ViewStyle>;
};
export type PagerSlot = number | 'gap';
const CELL = minTouchTarget;
/**
* The slots to show: the first and last page, the current one with its
* siblings, and a gap where pages are skipped. Past a few pages the number of
* slots stays the same, so the control never changes width.
*/
export function pagerSlots(page: number, count: number, siblings = 0): PagerSlot[] {
const slots = siblings * 2 + 5;
if (count <= slots) return Array.from({ length: count }, (_, i) => i + 1);
const left = Math.max(page - siblings, 1);
const right = Math.min(page + siblings, count);
const leftGap = left > 3;
const rightGap = right < count - 2;
const span = 3 + siblings * 2;
if (!leftGap && rightGap) {
return [...Array.from({ length: span }, (_, i) => i + 1), 'gap', count];
}
if (leftGap && !rightGap) {
return [1, 'gap', ...Array.from({ length: span }, (_, i) => count - span + i + 1)];
}
return [1, 'gap', ...Array.from({ length: right - left + 1 }, (_, i) => left + i), 'gap', count];
}
/**
* Pagination with a pill that glides to the current page. The row keeps its
* width: when the window of numbers moves, the digits roll to their new
* values in place instead of the buttons shifting about.
*/
export function Pager({
page,
pageCount,
onPageChange,
siblings = 0,
accessibilityLabel = 'Pages',
haptics = true,
testID,
style,
}: PagerProps) {
const { colors, radius } = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const count = Math.max(1, pageCount);
const current = Math.min(Math.max(1, page), count);
const slots = pagerSlots(current, count, siblings);
const at = slots.indexOf(current);
const x = useSharedValue(at * CELL);
useEffect(() => {
x.set(reduced ? at * CELL : withSpring(at * CELL, spring('snappy')));
}, [at, reduced, x]);
const pill = useAnimatedStyle(() => ({ transform: [{ translateX: x.get() }] }));
const go = (p: number) => {
const next = Math.min(Math.max(1, p), count);
if (next === current) return;
haptic('tick');
onPageChange(next);
};
return (
<View testID={testID} accessibilityLabel={accessibilityLabel} style={[styles.row, style]}>
<Arrow dir={-1} disabled={current === 1} onPress={() => go(current - 1)} />
<View style={styles.slots}>
<Animated.View
pointerEvents="none"
style={[styles.pill, { backgroundColor: colors.ember, borderRadius: radius.md }, pill]}
/>
{slots.map((slot, i) =>
slot === 'gap' ? (
<View
key={`gap-${i}`}
style={styles.cell}
accessibilityElementsHidden
importantForAccessibility="no"
>
<Text style={{ color: colors.textSubtle }}>…</Text>
</View>
) : (
<Slot
key={`slot-${i}`}
value={slot}
current={slot === current}
reduced={reduced}
onPress={() => go(slot)}
/>
),
)}
</View>
<Arrow dir={1} disabled={current === count} onPress={() => go(current + 1)} />
</View>
);
}
type SlotProps = { value: number; current: boolean; reduced: boolean; onPress: () => void };
/** One numbered button. When its number changes, the old one rolls out and the new one in. */
function Slot({ value, current, reduced, onPress }: SlotProps) {
const { colors, font } = useKinetikTheme();
const [roll, setRoll] = useState({ value, prev: value, dir: 1, n: 0 });
if (roll.value !== value) {
setRoll({ value, prev: roll.value, dir: value > roll.value ? 1 : -1, n: roll.n + 1 });
}
const p = useSharedValue(1);
useEffect(() => {
if (roll.n === 0 || reduced) return;
p.set(0);
p.set(withTiming(1, { duration: durations.base, easing: easings.standard }));
}, [roll.n, reduced, p]);
const incoming = useAnimatedStyle(() => ({
opacity: p.get(),
transform: [{ translateY: (1 - p.get()) * 10 * roll.dir }],
}));
const outgoing = useAnimatedStyle(() => ({
opacity: 1 - p.get(),
transform: [{ translateY: -p.get() * 10 * roll.dir }],
}));
const text = {
color: current ? colors.onEmber : colors.text,
fontSize: font.size.md,
fontWeight: current ? font.weight.semibold : font.weight.regular,
fontVariant: ['tabular-nums' as const],
};
return (
<Pressable
onPress={onPress}
accessibilityRole="button"
accessibilityLabel={`Page ${value}`}
accessibilityState={{ selected: current }}
style={styles.cell}
>
{roll.prev !== roll.value ? (
<Animated.Text style={[text, styles.ghost, outgoing]}>{roll.prev}</Animated.Text>
) : null}
<Animated.Text style={[text, incoming]}>{value}</Animated.Text>
</Pressable>
);
}
function Arrow({
dir,
disabled,
onPress,
}: {
dir: 1 | -1;
disabled: boolean;
onPress: () => void;
}) {
const { colors, radius } = useKinetikTheme();
return (
<Pressable
onPress={onPress}
disabled={disabled}
accessibilityRole="button"
accessibilityLabel={dir === 1 ? 'Next page' : 'Previous page'}
accessibilityState={{ disabled }}
style={({ pressed }) => [
styles.cell,
{
borderRadius: radius.md,
backgroundColor: pressed ? colors.surfaceSunken : undefined,
opacity: disabled ? 0.3 : 1,
},
]}
>
<View style={[styles.icon, { transform: [{ scaleX: dir }] }]}>
<View style={[styles.stroke, styles.up, { backgroundColor: colors.text }]} />
<View style={[styles.stroke, styles.down, { backgroundColor: colors.text }]} />
</View>
</Pressable>
);
}
const styles = StyleSheet.create({
row: { flexDirection: 'row', alignItems: 'center', alignSelf: 'center' },
slots: { flexDirection: 'row' },
pill: { position: 'absolute', left: 4, top: 4, width: CELL - 8, height: CELL - 8 },
cell: {
width: CELL,
height: CELL,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
ghost: { position: 'absolute' },
icon: { width: 14, height: 14 },
stroke: { position: 'absolute', width: 8, height: 1.8, borderRadius: 1, left: 4 },
up: { top: 4.2, transform: [{ rotate: '45deg' }] },
down: { top: 8.8, transform: [{ rotate: '-45deg' }] },
});