Scrolling Pagination Dots

Displays an animated and horizontally scrolling set of pagination dots, ideal for carousels or sliders, showing a limited number of dots at a time with opacity transitions.

react-nativepaginationdotscarouselsliderreanimatedanimationui-component

Installation

ScrollingPaginationDots.tsx
import React from 'react';
import { View, StyleSheet, StyleProp, ViewStyle, Dimensions } from 'react-native';
import Animated, {
useAnimatedStyle,
interpolate,
Extrapolation,
SharedValue,
} from 'react-native-reanimated';

const { width: defaultSlideWidth } = Dimensions.get('window');

//Individual Dot Component
// Animation is driven by shared value `scrollX`.
type DotProps = {
index: number;
scrollX: SharedValue<number>;
slideWidth: number;
dotSize: number;
spacing: number;
dotColor: string;
inactiveDotColor?: string;
inactiveDotOpacity: number;
dotStyle?: StyleProp<ViewStyle>;
maxVisibleDotsForAnimation: number;
};

const Dot: React.FC<DotProps> = React.memo(({
index,
scrollX,
slideWidth,
dotSize,
spacing,
dotColor,
inactiveDotColor,
inactiveDotOpacity,
dotStyle,
maxVisibleDotsForAnimation,
}) => {
const animatedDotStyle = useAnimatedStyle(() => {
  'worklet';
  // Current page index based on scroll position (can be a float during scroll)
  const currentIndexFloat = scrollX.value / slideWidth;

  // Distance of this dot's index from the current effective page index
  const distance = Math.abs(index - currentIndexFloat);

  // Define the range over which dots will scale/fade.
  // For maxVisibleDotsForAnimation = 5, FADE_RANGE will be 2.
  // This means dots at distance 0, 1, 2 from the active dot will be in the transition.
  const FADE_RANGE = (maxVisibleDotsForAnimation - 1) / 4;
  // Ensure fade range is at least 0.5 to prevent division by zero or non-sensical interpolation
  // if maxVisibleDotsForAnimation is 1.
  const effectiveFadeRange = Math.max(0.5, FADE_RANGE);

  //you can add for example scaling of the active dots aswell here

  // Opacity Interpolation:
  // - At distance 0 (active dot): 1 (fully opaque)
  // - At distance effectiveFadeRange: inactiveDotOpacity
  const opacity = interpolate(
    distance,
    [0, effectiveFadeRange],
    [1, inactiveDotOpacity],
    Extrapolation.CLAMP
  );

  return {
    opacity,
  };
});

return (
  <Animated.View
    style={[
      styles.dotBase,
      {
        width: dotSize,
        height: dotSize,
        borderRadius: dotSize * 2,
        marginHorizontal: spacing / 2,
        backgroundColor: inactiveDotColor || dotColor,
      },
      dotStyle,
      animatedDotStyle,
    ]}
  />
);
});

type ScrollingPaginationDotsProps = {
count: number;
scrollX: SharedValue<number>;
dotColor: string; // Color for the active dot (at full opacity)
inactiveDotColor?: string; // Base color for dots; if not provided, dotColor is used
dotSize?: number;
spacing?: number;
slideWidth?: number; // Width of each slide in the FlatList
containerStyle?: StyleProp<ViewStyle>;
dotStyle?: StyleProp<ViewStyle>;
inactiveDotOpacity?: number; // Opacity of dots at the edge of the visible window
maxVisibleDots?: number; // Max dots to display in the container (ideally odd)
};

const ScrollingPaginationDots: React.FC<ScrollingPaginationDotsProps> = ({
count,
scrollX,
dotColor,
inactiveDotColor,
dotSize = 8,
spacing = 8,
slideWidth = defaultSlideWidth,
containerStyle,
dotStyle,
inactiveDotOpacity = 0.3,
maxVisibleDots = 5,
}) => {
// Ensure maxVisibleDots is odd for a visually centered active dot effect.
// If an even number is provided, increment to the next odd number.
const actualMaxVisibleDots = maxVisibleDots % 2 === 0 ? maxVisibleDots + 1 : maxVisibleDots;

// Calculate the width of the container needed to show `actualMaxVisibleDots`.
// This container will have `overflow: 'hidden'` to act as a viewport.
const numDotsInViewport = Math.min(count, actualMaxVisibleDots);
const viewportWidth = numDotsInViewport > 0
  ? (numDotsInViewport * dotSize) + (numDotsInViewport > 1 ? (numDotsInViewport - 1) * spacing : 0)
  : 0;

// This container will be translated horizontally to keep the active group of dots centered.
const animatedInnerContainerStyle = useAnimatedStyle(() => {
  'worklet';
  // If the total number of dots is less than or equal to what can be shown no translation is needed
  if (count <= actualMaxVisibleDots) {
    return { transform: [{ translateX: 0 }] };
  }

  const currentIndexFloat = scrollX.value / slideWidth;

  // Calculate the translation needed to center the currentIndexFloat
  const currentDotCenterOffset = (currentIndexFloat * (dotSize + spacing)) + (dotSize / 2);
  // The X offset where we want this dot to appear in the center
  const viewportCenter = viewportWidth / 2;
  
  let translateX = viewportCenter - currentDotCenterOffset;

  // Clamp the translation to prevent over-scrolling the dot strip.
  // Total width of all dots laid out:
  const totalDotStripWidth = (count * dotSize) + ((count - 1) * spacing);
  // Maximum positive translation (when first few dots are shown):
  const maxTranslateX = 0; // Or slightly positive if padding is desired at the start
  // Minimum negative translation (when last few dots are shown):
  const minTranslateX = viewportWidth - totalDotStripWidth - dotSize;

  translateX = Math.max(minTranslateX, Math.min(maxTranslateX, translateX));

  return {
    transform: [{ translateX }],
  };
});

if (count <= 0) {
  return <></>;
}

const isScrollable = count > actualMaxVisibleDots;

return (
  <View
    style={[
      styles.outerContainer,
      { width: viewportWidth }, // Fixed width for the viewport
      !isScrollable && { justifyContent: 'center' },
      containerStyle,
    ]}
  >
    <Animated.View
      style={[
        styles.dotsInnerContainer,
        isScrollable ? animatedInnerContainerStyle : {}, // Apply translation if scrollable
      ]}
    >
      {[...Array(count)].map((_, index) => (
        <Dot
          key={`dot-${index}`}
          index={index}
          scrollX={scrollX}
          slideWidth={slideWidth}
          dotSize={dotSize}
          spacing={spacing}
          dotColor={dotColor}
          inactiveDotColor={inactiveDotColor}
          inactiveDotOpacity={inactiveDotOpacity}
          dotStyle={dotStyle}
          maxVisibleDotsForAnimation={actualMaxVisibleDots}
        />
      ))}
    </Animated.View>
  </View>
);
};

const styles = StyleSheet.create({
outerContainer: {
  flexDirection: 'row',
  alignItems: 'center',
  overflow: 'hidden',   
},
dotsInnerContainer: {
  flexDirection: 'row',
  alignItems: 'center',
},
dotBase: {
  borderRadius: 16
  // Base styles
},
});

export default ScrollingPaginationDots;

Usage

Sets up a horizontal FlatList to display a series of images, using react-native-reanimated to track scroll position and synchronizes it with a ScrollingPaginationDots component to provide visual feedback of the current image.

ImageCarouselPage.tsx
import React, { useRef } from 'react';
import {
View,
StyleSheet,
FlatList,
Image,
Dimensions,
SafeAreaView,
} from 'react-native';
import Animated, {
useSharedValue,
useAnimatedScrollHandler,
} from 'react-native-reanimated';
import { useAppColors } from '@/hooks/useAppColors';
import ScrollingPaginationDots from '@/components/ui/PaginationDots';

const { width: screenWidth, height: screenHeight } = Dimensions.get('window');

// Sample Image Data (replace with your actual image URIs or local requires)
const SAMPLE_IMAGES = [
{ id: '1', uri: `https://picsum.photos/seed/picsum1/${Math.round(screenWidth)}/${Math.round(screenHeight * 0.5)}` },
{ id: '2', uri: `https://picsum.photos/seed/picsum2/${Math.round(screenWidth)}/${Math.round(screenHeight * 0.5)}` },
{ id: '3', uri: `https://picsum.photos/seed/picsum3/${Math.round(screenWidth)}/${Math.round(screenHeight * 0.5)}` },
{ id: '4', uri: `https://picsum.photos/seed/picsum4/${Math.round(screenWidth)}/${Math.round(screenHeight * 0.5)}` },
{ id: '5', uri: `https://picsum.photos/seed/picsum5/${Math.round(screenWidth)}/${Math.round(screenHeight * 0.5)}` },
{ id: '6', uri: `https://picsum.photos/seed/picsum6/${Math.round(screenWidth)}/${Math.round(screenHeight * 0.5)}` },
{ id: '7', uri: `https://picsum.photos/seed/picsum7/${Math.round(screenWidth)}/${Math.round(screenHeight * 0.5)}` },
{ id: '8', uri: `https://picsum.photos/seed/picsum8/${Math.round(screenWidth)}/${Math.round(screenHeight * 0.5)}` },
];

type ImageItem = {
id: string;
uri: string;
};
export default function ImageCarouselPage() {

const scrollX = useSharedValue(0);
const flatListRef = useRef<FlatList<ImageItem>>(null);
const colors = useAppColors();

// Reanimated scroll handler to update scrollX
const scrollHandler = useAnimatedScrollHandler(
  (event) => {
    scrollX.value = event.contentOffset.x;
  },
  []
);

const viewabilityConfig = useRef({
  itemVisiblePercentThreshold: 50, // Item is considered visible if 50% is on screen
}).current;

// Render a single image slide
const renderImageItem = ({ item }: { item: ImageItem }) => {
  return (
    <View style={styles.slide}>
      <Image source={{ uri: item.uri }} style={styles.image} resizeMode="cover" />
    </View>
  );
};

return (
  <SafeAreaView style={styles.safeArea}>
    <View style={styles.container}>
      <Animated.FlatList
        ref={flatListRef}
        data={SAMPLE_IMAGES}
        renderItem={renderImageItem}
        keyExtractor={(item) => item.id}
        horizontal
        pagingEnabled
        showsHorizontalScrollIndicator={false}
        onScroll={scrollHandler}
        scrollEventThrottle={16} 
        viewabilityConfig={viewabilityConfig}
      />
      <View style={styles.paginationContainer}>
        <ScrollingPaginationDots
          count={SAMPLE_IMAGES.length}
          scrollX={scrollX}
          slideWidth={screenWidth} 
          dotColor={colors.Neutral700}        
          inactiveDotColor={colors.Neutral500}     
          dotSize={10}
          spacing={8}
          inactiveDotOpacity={0.5}
          maxVisibleDots={5} 
        />
      </View>
    </View>
  </SafeAreaView>
);
};

const styles = StyleSheet.create({
safeArea: {
  flex: 1,
},
container: {
  flex: 1,
},
slide: {
  width: screenWidth, 
  height: screenHeight * 0.5, 
  justifyContent: 'center',
  alignItems: 'center',
},
image: {
  width: '90%',
  height: '90%', 
  borderRadius: 10, 
},
paginationContainer: {
  height: 50,
  justifyContent: 'center',
  alignItems: 'center',
},
});

Props

PropTypeDefaultRequiredDescription
countnumberYesTotal number of dots to display, corresponding to the number of slides.
scrollXSharedValue<number>YesA react-native-reanimated shared value representing the horizontal scroll position of the associated scrollable view (e.g., FlatList).
dotColorstringYesThe color of the active dot and the base color for inactive dots if inactiveDotColor is not provided.
inactiveDotColorstringNoThe color for inactive dots. If not provided, dotColor is used with inactiveDotOpacity.
dotSizenumber8NoThe diameter of each pagination dot.
spacingnumber8NoThe space between each dot.
slideWidthnumberDimensions.get('window').widthNoThe width of a single slide in the scrollable view. Used to calculate the current active dot based on scrollX.
containerStyleStyleProp<ViewStyle>NoCustom styles for the outer View container that acts as the viewport for the dots.
dotStyleStyleProp<ViewStyle>NoCustom styles applied to each individual dot Animated.View. Merged with default dot styles.
inactiveDotOpacitynumber0.3NoThe opacity of dots that are further away from the currently active dot.
maxVisibleDotsnumber5NoThe maximum number of dots to be visible in the viewport at any time. .