Kinetik UI

Page Dots

.md

Page dots that follow the scroll: a worm that stretches to the next page, or a dot that widens into a pill.

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

Installation

Kinetik CLI
npx kinetik-ui add page-dots

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/page-dots.json

Install manually

  1. Install dependencies
    npx expo install react-native-reanimated react-native-worklets
  2. Copy the source files
    • components/kinetik/page-dots.tsx
    View source files

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

  • worm stretches the current dot towards the next page and lets its tail catch up; expand widens 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 index in step with the scroll (round the position), since screen readers and taps read it.

Props

PageDotsProps

PropTypeDefaultDescription
countrequirednumber—
indexnumber0The current page. With `progress`, still pass it: screen readers and taps read it.
progressSharedValue<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`.
sizenumber8Dot diameter. Defaults to 8.
colorstring—Colour of the current page. Defaults to the theme azure.
accessibilityLabelstring'Page'Names the pager. Defaults to 'Page'.
testIDstring—
styleStyleProp<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 },
});