Expokit
AboutDocsComponentsChartsBlocksPricing
Sign inGet started
All components

Inputs

CalendarNewDate PickerNewFloating InputOTP InputPassword InputSearch BarSelectSliderStepperTag InputTextareaTime PickerNew

Selection

CheckboxChip GroupColor SwatchProRadio GroupRatingProSegmented ControlSwitch

Buttons

ButtonFloating Action ButtonProLike ButtonProMorph ButtonProShimmer ButtonProSlide to ConfirmProStateful Button

Feedback

AlertProgressSkeletonSpinnerToast

Overlays

Action SheetNewBottom SheetDialogDrawerNew

Display

AccordionAvatarBadgeCardCarouselProList ItemNewPagination DotsNewSwipeable RowProTimelineNew

Navigation

Bottom NavNewProgress StepsNewTabs

Motion

Number TickerProText Animation
Pagination Dots
Sponsor ExpoKit

Free stays free. Tip once — keep shipping.

Support
About
Docs
IntroductionWhat ExpoKit is and how it worksInstallationAdd ExpoKit to an Expo appThemingColours, radius and dark modeCLIinit, add, login and updateAI assistants (MCP)Cursor, Claude, Antigravity and moreRun on your phoneTry components in Expo GoChangelogWhat shipped recently
npx @expokit/cli@latest initQuick start
Components
Liquid Glass8 iOS 26 glass pieces3DNew14 tilt, flip and stack
Inputs 12
  • CalendarNew
  • Date PickerNew
  • Floating Input
  • OTP Input
  • Password Input
  • +7 more
Selection 7
  • Checkbox
  • Chip Group
  • Color SwatchPro
  • Radio Group
  • RatingPro
  • +2 more
Buttons 7
  • Button
  • Floating Action ButtonPro
  • Like ButtonPro
  • Morph ButtonPro
  • Shimmer ButtonPro
  • +2 more
Feedback 5
  • Alert
  • Progress
  • Skeleton
  • Spinner
  • Toast
Overlays 4
  • Action SheetNew
  • Bottom Sheet
  • Dialog
  • DrawerNew
Display 9
  • Accordion
  • Avatar
  • Badge
  • Card
  • CarouselPro
  • +4 more
Navigation 3
  • Bottom NavNew
  • Progress StepsNew
  • Tabs
Motion 2
  • Number TickerPro
  • Text Animation
70 components · 347 live variantsAll components
Charts

Cartesian

  • Area Chart
  • Bar Chart
  • Line Chart
  • Combo Chart

Circular

  • Pie & Donut
  • Radial Chart
  • Radar Chart
  • Polar AreaPro

3D & Pixel

  • Isometric BarsPro
  • Pixel BarsPro

Specialty

  • TreemapPro
  • WaterfallPro
  • BulletPro
  • TimelinePro
  • Gauge
  • Candlestick
  • Heatmap
  • Funnel

Classic

  • Sparkline
  • Progress Rings
  • MultiLine (classic)
  • StackedBar (classic)
  • Ranking Bars (classic)

Guides

  • Usage & config
  • Patterns & effects
  • Show / hide anything
  • Chart theming
  • Interactions
23 charts, built on the open-source expo-charts engineAll charts
Blocks

Screens

ChatProAn AI assistant chat screenCheckoutProA premium checkoutDashboardProA finance / analytics home screenInboxProA messaging inboxOnboardingProA minimal swipeable welcome flowProfileProAccount profile for any appSearch ResultsProA clean, monochrome search screenSettingsProAn iOS-style grouped settings screenSign InProOne configurable sign-in flow modelled on top mobile apps
9 complete screens — drop one in, wire your dataSee them on devices All blocks
Pricing
DocsComponentsDisplayPagination Dots

Pagination Dots

Scan with Expo Go

Pagination Dots with all its variants, native in Expo Go. No account needed.

No Expo Go? Open in your browser

The active dot stretches into a pill that follows your swipe live.

<PaginationDots />3 variantsiOS · Android · Expo Go
9:41

Sunday 18 October

9:41

Installation#

One command copies the source into your app — you own the code and can change anything.

Usage#

Colours, radius and light / dark come from useKit(). Wrap your app in <KitProvider accent="#6366f1" radius={14}> to re-skin every component at once.

Props#

PropTypeDefault
count

Number of pages.

number—
index

Active page; changes spring the indicator across.

number0
progress

Live page position (e.g. Animated.divide(scrollX, width)) for smooth tracking while swiping. Use useNativeDriver: false for the 'dots' variant.

Animated.Value | AnimatedInterpolation | number—
variant

Stretching pill · growing dot · filling segments · rolling "2 / 5".

"dots" | "scale" | "line" | "numbers""dots"
size

6 / 8 / 10pt dots.

"sm" | "md" | "lg""md"
onPress

Makes dots tappable (44pt hit area) and enables accessibility increment / decrement.

(index: number) => void—
accent

Active colour.

stringtheme accent
inactiveColor

Inactive dot colour.

string18% foreground
accessibilityLabel

Announced with "2 of 5".

string"Page"
style

Style for the row of dots (the numbers pill or line track in those variants).

StyleProp<ViewStyle>—

Behaviour & accessibility#

Motion
Pass a live progress value (scroll offset ÷ page width) and the indicator tracks the finger; with index alone it springs across. Variants stretch a pill, scale a dot, fill line segments or roll the page number.
Touch
With onPress, each dot becomes a button with an enlarged hit area that reports its page.
Screen readers
Read as one element with 'N of M'. With onPress it is adjustable, so swiping up or down changes the page.
Platforms
The 'dots' variant animates widths, so drive its progress with useNativeDriver: false. The other variants work with either driver.

Source#

PreviousList ItemNextSwipeable Row

Did you like the content?

Coding agents

Chat apps

Other

9:41

Onboarding

Pagination Dots

Manual installation
  1. Copy the files

    Create each file at the same path and paste its code. @/ points at your src/ folder (the default in new Expo apps). Skip any file you already have, like lib/kit.tsx.

    • src/components/ui/pagination-dots.tsx266 lines
    • src/lib/kit.tsx220 lines

    Want to read it first? Every file is in Source below.

  2. Use it

    App.tsx
    import { PaginationDots } from "@/components/ui/pagination-dots";

    See Usage for a full example.

Example.tsx
import { useState } from 'react';import { PaginationDots } from '@/components/ui/pagination-dots'; const [page, setPage] = useState(0);<PaginationDots count={4} index={page} onPress={setPage} />

Every variant above has a complete, runnable example in its Code tab.

src/components/ui/pagination-dots.tsx
import React, { useEffect, useMemo, useRef, useState } from 'react';import {  Animated,  Pressable,  StyleSheet,  Text,  View,  type AccessibilityActionEvent,  type LayoutChangeEvent,  type StyleProp,  type ViewStyle,} from 'react-native'; import { alpha, motion, useKit } from '@/lib/kit'; export type PaginationDotsVariant = 'dots' | 'scale' | 'line' | 'numbers';export type PaginationDotsSize = 'sm' | 'md' | 'lg'; /** A page position: 0 = first page, 1.5 = halfway between the second and third. */export type PaginationProgress = Animated.Value | Animated.AnimatedInterpolation<number> | number; type Position = ReturnType<typeof Animated.add>; export interface PaginationDotsProps {  /** Number of pages. */  count: number;  /** Active page. Changes spring the indicator across. Ignored when `progress` is an Animated value. */  index?: number;  /**   * Live page position, e.g. `Animated.divide(scrollX, pageWidth)`, so the indicator follows the   * finger while swiping. Drive it with `useNativeDriver: false` for the 'dots' variant (it   * animates widths); 'scale', 'line' and 'numbers' work with either driver.   */  progress?: PaginationProgress;  /** 'dots' active dot stretches into a pill · 'scale' active dot grows · 'line' progress segments · 'numbers' "2 / 5". */  variant?: PaginationDotsVariant;  size?: PaginationDotsSize;  /** Makes each dot a button (with a 44pt hit area) that reports its page. */  onPress?: (index: number) => void;  /** Active colour (default: theme accent). */  accent?: string;  /** Inactive dot colour (default: a soft tint of the foreground). */  inactiveColor?: string;  accessibilityLabel?: string;  style?: StyleProp<ViewStyle>;} const SIZES: Record<PaginationDotsSize, { dot: number; gap: number; pill: number; line: number; font: number }> = {  sm: { dot: 6, gap: 6, pill: 18, line: 3, font: 12 },  md: { dot: 8, gap: 8, pill: 24, line: 4, font: 13 },  lg: { dot: 10, gap: 10, pill: 32, line: 5, font: 15 },}; /** * Page indicator for carousels and onboarding. Feed it a live `progress` value and it tracks the * swipe continuously: the active dot stretches into a pill that hands its width to the next dot, * scales up, fills a segmented line or rolls the page number. */export function PaginationDots({  count,  index = 0,  progress,  variant = 'dots',  size = 'md',  onPress,  accent,  inactiveColor,  accessibilityLabel = 'Page',  style,}: PaginationDotsProps) {  const { colors, radius } = useKit();  const tint = accent ?? colors.accent;  const idle = inactiveColor ?? alpha(colors.foreground, 0.18);  const s = SIZES[size];  const n = Math.max(1, count);   // A spring-driven position when we aren't handed an Animated value.  const external = typeof progress === 'object' ? progress : null;  const target = typeof progress === 'number' ? progress : index;  const internal = useRef(new Animated.Value(target)).current;  useEffect(() => {    if (external) return;    if (typeof progress === 'number') internal.setValue(target);    else Animated.spring(internal, { toValue: target, useNativeDriver: false, ...motion.snappy }).start();  }, [external, progress, target, internal]);  // Through Animated.add so it's one node type: calling interpolate on the Value | Interpolation union doesn't type-check.  const pos: Position = useMemo(() => Animated.add(external ?? internal, 0), [external, internal]);   // Rounded page for the 'numbers' label and accessibility.  const [page, setPage] = useState(Math.round(target));  useEffect(() => {    if (!external) {      setPage(Math.round(target));      return;    }    const id = external.addListener(({ value }: { value: number }) => setPage(Math.min(n - 1, Math.max(0, Math.round(value)))));    return () => external.removeListener(id);  }, [external, target, n]);   const pages = useMemo(() => Array.from({ length: n }, (_, i) => i), [n]);  const around = (i: number, outside: number, inside: number) =>    pos.interpolate({ inputRange: [i - 1, i, i + 1], outputRange: [outside, inside, outside], extrapolate: 'clamp' });   const onAction = (e: AccessibilityActionEvent) => {    if (!onPress) return;    if (e.nativeEvent.actionName === 'increment' && page < n - 1) onPress(page + 1);    if (e.nativeEvent.actionName === 'decrement' && page > 0) onPress(page - 1);  };   const a11y = {    accessible: true,    accessibilityRole: onPress ? ('adjustable' as const) : ('text' as const),    accessibilityLabel,    accessibilityValue: { min: 1, max: n, now: page + 1, text: `${page + 1} of ${n}` },    accessibilityActions: onPress ? [{ name: 'increment' as const }, { name: 'decrement' as const }] : undefined,    onAccessibilityAction: onAction,  };   const hit = (i: number, child: React.ReactNode, extra?: ViewStyle) =>    onPress ? (      <Pressable        key={i}        onPress={() => onPress(i)}        hitSlop={{ top: 18, bottom: 18, left: s.gap / 2, right: s.gap / 2 }}        accessibilityLabel={`Go to page ${i + 1}`}        style={extra}      >        {child}      </Pressable>    ) : (      <View key={i} style={extra}>        {child}      </View>    );   if (variant === 'numbers') {    return (      <View {...a11y} style={[styles.numbers, { borderRadius: radius.capsule(Math.ceil(s.font * 1.4) + 10), backgroundColor: alpha(colors.foreground, 0.06) }, style]}>        <RollingNumber value={page + 1} color={tint} size={s.font} />        <Text style={[styles.numText, { fontSize: s.font, color: colors.mutedForeground }]}> / {n}</Text>      </View>    );  }   if (variant === 'line') return <LineSegments {...{ pages, pos, s, tint, idle, a11y, hit, style }} />;   return (    <View {...a11y} style={[styles.row, { gap: s.gap, height: s.dot * (variant === 'scale' ? 1.8 : 1) }, style]}>      {pages.map((i) =>        variant === 'scale'          ? hit(              i,              <Animated.View                style={{                  width: s.dot,                  height: s.dot,                  borderRadius: s.dot,                  backgroundColor: tint,                  opacity: around(i, 0.28, 1),                  transform: [{ scale: around(i, 1, 1.55) }],                }}              />            )          : hit(              i,              // Width (JS driver) on the outer view, the accent fill fades on an inner view.              <Animated.View                style={{ width: around(i, s.dot, s.pill), height: s.dot, borderRadius: s.dot, backgroundColor: idle, overflow: 'hidden' }}              >                <Animated.View style={[StyleSheet.absoluteFill, { backgroundColor: tint, opacity: around(i, 0, 1) }]} />              </Animated.View>            )      )}    </View>  );} interface LineProps {  pages: number[];  pos: Position;  s: (typeof SIZES)[PaginationDotsSize];  tint: string;  idle: string;  a11y: object;  hit: (i: number, child: React.ReactNode, extra?: ViewStyle) => React.ReactNode;  style?: StyleProp<ViewStyle>;} /** Segments fill left-to-right as the position moves into each page. */function LineSegments({ pages, pos, s, tint, idle, a11y, hit, style }: LineProps) {  const [w, setW] = useState(0);  const n = pages.length;  const segW = w ? (w - s.gap * (n - 1)) / n : 0;  const onLayout = (e: LayoutChangeEvent) => setW(e.nativeEvent.layout.width);  return (    <View {...a11y} onLayout={onLayout} style={[styles.line, { gap: s.gap }, style]}>      {pages.map((i) =>        hit(          i,          <View style={{ height: s.line, borderRadius: s.line, backgroundColor: idle, overflow: 'hidden' }}>            {segW ? (              <Animated.View                style={{                  width: segW,                  height: s.line,                  borderRadius: s.line,                  backgroundColor: tint,                  transform: [                    {                      translateX: pos.interpolate({ inputRange: [i - 1, i], outputRange: [-segW, 0], extrapolate: 'clamp' }),                    },                  ],                }}              />            ) : null}          </View>,          styles.segment        )      )}    </View>  );} function RollingNumber({ value, color, size }: { value: number; color: string; size: number }) {  const v = useRef(new Animated.Value(1)).current;  const last = useRef(value);  const dir = value >= last.current ? 1 : -1;  useEffect(() => {    if (value === last.current) return;    last.current = value;    v.setValue(0);    Animated.spring(v, { toValue: 1, useNativeDriver: true, ...motion.snappy }).start();  }, [value, v]);  return (    <Animated.Text      style={[        styles.numText,        {          fontSize: size,          color,          fontWeight: '400',          opacity: v,          transform: [{ translateY: v.interpolate({ inputRange: [0, 1], outputRange: [dir * size * 0.7, 0] }) }],        },      ]}    >      {value}    </Animated.Text>  );} const styles = StyleSheet.create({  row: { flexDirection: 'row', alignItems: 'center', alignSelf: 'center' },  line: { flexDirection: 'row', alignItems: 'center', alignSelf: 'stretch' },  segment: { flex: 1, justifyContent: 'center' },  numbers: {    flexDirection: 'row',    alignItems: 'center',    alignSelf: 'center',    paddingHorizontal: 12,    paddingVertical: 5,    overflow: 'hidden',  },  numText: { fontVariant: ['tabular-nums'], fontWeight: '400' },});