Page Dots
Page dots that follow the scroll: a worm that stretches to the next page, or a dot that widens into a pill.
import { useEffect } from 'react';
import {
Pressable,
StyleSheet,
View,
type AccessibilityActionEvent,
type GestureResponderEvent,
type StyleProp,
type ViewStyle,
} from 'react-native';
import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
type SharedValue,
} from 'react-native-reanimated';
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';
/** Side padding of the touch area. */
const PAD = 8;
export type PageDotsProps = {
count: number;
/** The current page. With `progress`, still pass it: screen readers and taps read it. */
index?: number;
/**
* The scroll position in pages (1.5 is halfway from the second page to the
* third), e.g. from `useAnimatedScrollHandler`. The dots then follow the
* finger instead of jumping from page to page.
*/
progress?: SharedValue<number>;
/** Tapping a dot, or a screen reader swipe, asks for that page. */
onPageChange?: (index: number) => void;
/** `worm` stretches between dots; `expand` widens the current dot. Defaults to `worm`. */
variant?: 'worm' | 'expand';
/** Dot diameter. Defaults to 8. */
size?: number;
/** Colour of the current page. Defaults to the theme azure. */
color?: string;
/** Names the pager. Defaults to 'Page'. */
accessibilityLabel?: string;
testID?: string;
style?: StyleProp<ViewStyle>;
};
/**
* Page dots that move with the scroll. As a worm, the current dot stretches
* toward the next page and its tail catches up; expanded, the current dot
* widens into a pill and hands the width over as you swipe.
*/
export function PageDots({
count,
index = 0,
progress,
onPageChange,
variant = 'worm',
size = 8,
color,
accessibilityLabel = 'Page',
testID,
style,
}: PageDotsProps) {
const { colors } = useKinetikTheme();
const reduced = useReduceMotion();
const ink = color ?? colors.azure;
const gap = size;
const step = size + gap;
const wide = size * 3;
// Without a scroll position, follow `index` on a spring.
const own = useSharedValue(index);
useEffect(() => {
own.set(withSpring(index, spring('smooth', reduced)));
}, [index, reduced, own]);
const at = progress ?? own;
const current = Math.max(0, Math.min(count - 1, Math.round(index)));
const pick = (e: GestureResponderEvent) => {
if (!onPageChange) return;
// Relative to the pressable (the dots ignore touches), less its side padding.
const x = e.nativeEvent.locationX - PAD;
const extra = variant === 'expand' ? wide - size : 0;
const i = Math.round((x - size / 2 - (x > current * step ? extra : 0)) / step);
onPageChange(Math.max(0, Math.min(count - 1, i)));
};
const onAccessibilityAction = (e: AccessibilityActionEvent) => {
if (!onPageChange) return;
const next = current + (e.nativeEvent.actionName === 'increment' ? 1 : -1);
if (next >= 0 && next < count) onPageChange(next);
};
const worm = useAnimatedStyle(() => {
const p = Math.max(0, Math.min(count - 1, at.get()));
const i = Math.floor(p);
const f = p - i;
// The head leads over the first half of the move, the tail catches up over the second.
const head = i + Math.min(1, f * 2);
const tail = i + Math.max(0, f * 2 - 1);
return { left: tail * step, width: (head - tail) * step + size };
});
const row = (
<View pointerEvents="none" style={[styles.row, { gap, height: size }]}>
{Array.from({ length: count }, (_, i) =>
variant === 'expand' ? (
<ExpandDot
key={i}
index={i}
at={at}
size={size}
wide={wide}
ink={ink}
track={colors.track}
/>
) : (
<View
key={i}
style={{
width: size,
height: size,
borderRadius: size / 2,
backgroundColor: colors.track,
}}
/>
),
)}
{variant === 'worm' ? (
<Animated.View
style={[
styles.worm,
{ height: size, borderRadius: size / 2, backgroundColor: ink },
worm,
]}
/>
) : null}
</View>
);
return (
<Pressable
testID={testID}
onPress={pick}
disabled={!onPageChange}
accessible
accessibilityRole="adjustable"
accessibilityLabel={accessibilityLabel}
accessibilityValue={{
min: 1,
max: count,
now: current + 1,
text: `${current + 1} of ${count}`,
}}
accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
onAccessibilityAction={onAccessibilityAction}
style={[styles.hit, style]}
>
{row}
</Pressable>
);
}
type ExpandDotProps = {
index: number;
at: SharedValue<number>;
size: number;
wide: number;
ink: string;
track: string;
};
function ExpandDot({ index, at, size, wide, ink, track }: ExpandDotProps) {
const fill = useAnimatedStyle(() => {
const near = Math.max(0, 1 - Math.abs(at.get() - index));
return { width: size + (wide - size) * near, opacity: 0.25 + near * 0.75 };
});
return (
<View style={{ height: size, borderRadius: size / 2, backgroundColor: track }}>
<Animated.View
style={[{ height: size, borderRadius: size / 2, backgroundColor: ink }, fill]}
/>
</View>
);
}
const styles = StyleSheet.create({
hit: {
minHeight: minTouchTarget,
justifyContent: 'center',
alignSelf: 'center',
paddingHorizontal: PAD,
},
row: { flexDirection: 'row', alignItems: 'center' },
worm: { position: 'absolute', top: 0 },
});Installation
npx kinetik-ui add page-dotspnpm dlx kinetik-ui add page-dotsbunx kinetik-ui add page-dotsyarn dlx kinetik-ui add page-dotsThe 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/page-dots.json
Install manually
- Install dependencies
npx expo install react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/page-dots.tsx
Usage
import Animated, { useAnimatedScrollHandler, useSharedValue } from 'react-native-reanimated';
import { PageDots } from '@/components/kinetik/page-dots';
const progress = useSharedValue(0);
const onScroll = useAnimatedScrollHandler((e) => progress.set(e.contentOffset.x / pageWidth));
<Animated.ScrollView horizontal pagingEnabled onScroll={onScroll} scrollEventThrottle={16}>
{pages}
</Animated.ScrollView>
<PageDots count={pages.length} index={page} progress={progress} onPageChange={scrollToPage} />Pass the scroll position as progress and the dots follow the finger on the UI thread. Without it, they spring to index.
When to use
- Paging carousels, onboarding screens and photo viewers with up to about ten pages.
- Showing where you are in a short, swipeable sequence.
When not to use
- Many pages. Show "3 of 24" instead.
- As the only way to move between pages. Keep the swipe, and add buttons where it helps.
Notes
wormstretches the current dot towards the next page and lets its tail catch up;expandwidens the current dot and hands the width over as you swipe.- With
onPageChange, a tap picks the nearest dot and screen reader swipes ask for the next or previous page. Scroll your pager there. - Keep
indexin step with the scroll (round the position), since screen readers and taps read it.
Props
PageDotsProps
| Prop | Type | Default | Description |
|---|---|---|---|
countrequired | number | — | |
index | number | 0 | The current page. With `progress`, still pass it: screen readers and taps read it. |
progress | SharedValue<number> | — | The scroll position in pages (1.5 is halfway from the second page to the third), e.g. from `useAnimatedScrollHandler`. The dots then follow the finger instead of jumping from page to page. |
onPageChange | (index: number) => void | — | Tapping a dot, or a screen reader swipe, asks for that page. |
variant | 'worm' | 'expand' | 'worm' | `worm` stretches between dots; `expand` widens the current dot. Defaults to `worm`. |
size | number | 8 | Dot diameter. Defaults to 8. |
color | string | — | Colour of the current page. Defaults to the theme azure. |
accessibilityLabel | string | 'Page' | Names the pager. Defaults to 'Page'. |
testID | string | — | |
style | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- Reads the scroll position as a shared value on the UI thread, so it follows the finger without re-rendering. Nothing runs at rest.
- Measured
Pixel 9 emulator, API 35, release build · interaction median 0.85× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 147 MB app memory
Real-device measurement pending.
Accessibility
- Reduced motion
- Driven by the scroll, it still follows the finger; without a scroll position it jumps between pages.
- Screen readers
- An adjustable value ("Page, 2 of 5") whose increment and decrement ask for the next and previous page.
- Touch target
- The whole row is the target, 44 pt tall; a tap picks the nearest dot.
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/page-dots.tsx
import { useEffect } from 'react';
import {
Pressable,
StyleSheet,
View,
type AccessibilityActionEvent,
type GestureResponderEvent,
type StyleProp,
type ViewStyle,
} from 'react-native';
import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
type SharedValue,
} from 'react-native-reanimated';
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';
/** Side padding of the touch area. */
const PAD = 8;
export type PageDotsProps = {
count: number;
/** The current page. With `progress`, still pass it: screen readers and taps read it. */
index?: number;
/**
* The scroll position in pages (1.5 is halfway from the second page to the
* third), e.g. from `useAnimatedScrollHandler`. The dots then follow the
* finger instead of jumping from page to page.
*/
progress?: SharedValue<number>;
/** Tapping a dot, or a screen reader swipe, asks for that page. */
onPageChange?: (index: number) => void;
/** `worm` stretches between dots; `expand` widens the current dot. Defaults to `worm`. */
variant?: 'worm' | 'expand';
/** Dot diameter. Defaults to 8. */
size?: number;
/** Colour of the current page. Defaults to the theme azure. */
color?: string;
/** Names the pager. Defaults to 'Page'. */
accessibilityLabel?: string;
testID?: string;
style?: StyleProp<ViewStyle>;
};
/**
* Page dots that move with the scroll. As a worm, the current dot stretches
* toward the next page and its tail catches up; expanded, the current dot
* widens into a pill and hands the width over as you swipe.
*/
export function PageDots({
count,
index = 0,
progress,
onPageChange,
variant = 'worm',
size = 8,
color,
accessibilityLabel = 'Page',
testID,
style,
}: PageDotsProps) {
const { colors } = useKinetikTheme();
const reduced = useReduceMotion();
const ink = color ?? colors.azure;
const gap = size;
const step = size + gap;
const wide = size * 3;
// Without a scroll position, follow `index` on a spring.
const own = useSharedValue(index);
useEffect(() => {
own.set(withSpring(index, spring('smooth', reduced)));
}, [index, reduced, own]);
const at = progress ?? own;
const current = Math.max(0, Math.min(count - 1, Math.round(index)));
const pick = (e: GestureResponderEvent) => {
if (!onPageChange) return;
// Relative to the pressable (the dots ignore touches), less its side padding.
const x = e.nativeEvent.locationX - PAD;
const extra = variant === 'expand' ? wide - size : 0;
const i = Math.round((x - size / 2 - (x > current * step ? extra : 0)) / step);
onPageChange(Math.max(0, Math.min(count - 1, i)));
};
const onAccessibilityAction = (e: AccessibilityActionEvent) => {
if (!onPageChange) return;
const next = current + (e.nativeEvent.actionName === 'increment' ? 1 : -1);
if (next >= 0 && next < count) onPageChange(next);
};
const worm = useAnimatedStyle(() => {
const p = Math.max(0, Math.min(count - 1, at.get()));
const i = Math.floor(p);
const f = p - i;
// The head leads over the first half of the move, the tail catches up over the second.
const head = i + Math.min(1, f * 2);
const tail = i + Math.max(0, f * 2 - 1);
return { left: tail * step, width: (head - tail) * step + size };
});
const row = (
<View pointerEvents="none" style={[styles.row, { gap, height: size }]}>
{Array.from({ length: count }, (_, i) =>
variant === 'expand' ? (
<ExpandDot
key={i}
index={i}
at={at}
size={size}
wide={wide}
ink={ink}
track={colors.track}
/>
) : (
<View
key={i}
style={{
width: size,
height: size,
borderRadius: size / 2,
backgroundColor: colors.track,
}}
/>
),
)}
{variant === 'worm' ? (
<Animated.View
style={[
styles.worm,
{ height: size, borderRadius: size / 2, backgroundColor: ink },
worm,
]}
/>
) : null}
</View>
);
return (
<Pressable
testID={testID}
onPress={pick}
disabled={!onPageChange}
accessible
accessibilityRole="adjustable"
accessibilityLabel={accessibilityLabel}
accessibilityValue={{
min: 1,
max: count,
now: current + 1,
text: `${current + 1} of ${count}`,
}}
accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
onAccessibilityAction={onAccessibilityAction}
style={[styles.hit, style]}
>
{row}
</Pressable>
);
}
type ExpandDotProps = {
index: number;
at: SharedValue<number>;
size: number;
wide: number;
ink: string;
track: string;
};
function ExpandDot({ index, at, size, wide, ink, track }: ExpandDotProps) {
const fill = useAnimatedStyle(() => {
const near = Math.max(0, 1 - Math.abs(at.get() - index));
return { width: size + (wide - size) * near, opacity: 0.25 + near * 0.75 };
});
return (
<View style={{ height: size, borderRadius: size / 2, backgroundColor: track }}>
<Animated.View
style={[{ height: size, borderRadius: size / 2, backgroundColor: ink }, fill]}
/>
</View>
);
}
const styles = StyleSheet.create({
hit: {
minHeight: minTouchTarget,
justifyContent: 'center',
alignSelf: 'center',
paddingHorizontal: PAD,
},
row: { flexDirection: 'row', alignItems: 'center' },
worm: { position: 'absolute', top: 0 },
});