Pagination
A draggable pagination indicator
Last updated on
Manual
Install the following dependencies:
npm install react-native-reanimated react-native-worklets expo-haptics react-native-gesture-handlerCopy and paste the following code into your project.
component/molecules/pagination
import React, { useEffect } from "react";import { Dimensions, StyleProp, StyleSheet, View, ViewProps, ViewStyle,} from "react-native";import Animated, { useDerivedValue, withTiming, Easing, SharedValue, useAnimatedStyle, interpolateColor, interpolate, Extrapolation, useSharedValue,} from "react-native-reanimated";import { GestureDetector, Gesture, PanGesture,} from "react-native-gesture-handler";import { PaginationProps } from "./Pagination.types";import { scheduleOnRN } from "react-native-worklets";import { impactAsync, ImpactFeedbackStyle } from "expo-haptics";const ACTIVE_COLOR: string = "#c4c4c4";const INACTIVE_COLOR: string = "#363636";const CURRENT_COLOR: string = `#b724d4`;const DOT_SIZE: number = 10;const BORDER_RADIUS: number = 100;const DOT_CONTAINER = 24;const INITIAL_CONTAINER_STYLE: ViewStyle = { backgroundColor: "#9a3df2",};const { width } = Dimensions.get("window");export function Pagination<T extends PaginationProps>( props: T & ViewProps,): React.ReactElement { const { activeIndex, totalItems, dotSize = props.dotSize ?? DOT_SIZE, inactiveColor = props.inactiveColor ?? INACTIVE_COLOR, activeColor = props.activeColor ?? ACTIVE_COLOR, currentColor = props.currentColor ?? CURRENT_COLOR, borderRadius: borderRadius = props.borderRadius ?? BORDER_RADIUS, dotContainer: dotContainer = props.dotContainer ?? DOT_CONTAINER, onIndexChange, containerStyle = props.containerStyle ?? INITIAL_CONTAINER_STYLE, } = props; const clampedActiveIndex = Math.min(Math.max(activeIndex, 0), totalItems - 1); const scale = useSharedValue<number>(1); const index_ = useSharedValue<number>(clampedActiveIndex); useEffect(() => { const _shapedIndex = (index_.value = Math.min( Math.max(activeIndex, 0), totalItems - 1, )); if (onIndexChange) { onIndexChange(_shapedIndex); } }, [activeIndex, totalItems]); const longPressGesture: PanGesture = Gesture.Pan() .onStart(() => { scale.value = withTiming<number>(1.2, { duration: 150 }); }) .onUpdate((e) => { const index = Math.floor(e.absoluteX / (width / totalItems)); if (index >= 0 && index < totalItems) { if (index_.value !== index) { scheduleOnRN(impactAsync, ImpactFeedbackStyle.Medium); } index_.value = index; if (onIndexChange) { scheduleOnRN(onIndexChange, index); } } }) .onEnd(() => { scale.value = withTiming<number>(1, { duration: 150 }); }) .onFinalize(() => { scale.value = withTiming<number>(1, { duration: 150 }); }); const animatedStyle = useAnimatedStyle<ViewStyle>(() => { return { transform: [{ scale: scale.value }], }; }); const animation = useDerivedValue<number>(() => { return withTiming<number>(index_.value, { easing: Easing.linear, duration: 300, }); }); return ( <GestureDetector gesture={longPressGesture}> <Animated.View style={[animatedStyle]} {...props}> <View style={{ flexDirection: "row" }}> <Indicator animation={animation} dotContainer={dotContainer} containerStyle={containerStyle as StyleProp<ViewStyle>} radius={borderRadius} /> {[...Array(totalItems).keys()].map((index) => ( <Dot key={`index-${index}`} index={index} animation={animation} activeColor={activeColor} inactiveColor={inactiveColor} currentColor={currentColor} dotSize={dotSize} borderRadius={borderRadius} dotContainer={dotContainer} /> ))} </View> </Animated.View> </GestureDetector> );}function Indicator({ animation, dotContainer, radius, containerStyle,}: { animation: SharedValue<number>; dotContainer?: number; radius?: number; containerStyle?: StyleProp<ViewStyle>;}) { const indicatorAnimatedStyle = useAnimatedStyle(() => { const width = DOT_CONTAINER + DOT_CONTAINER * animation.value; const opacity = interpolate( animation.value, [0, 0.01], [0, 1], Extrapolation.CLAMP, ); return { width, opacity: withTiming<number>(opacity, { duration: 200, easing: Easing.linear, }), }; }); return ( <Animated.View style={[ { height: dotContainer, position: "absolute", left: 0, top: 0, borderRadius: radius, }, containerStyle, indicatorAnimatedStyle, ]} /> );}function Dot<T extends {}>({ index, animation, inactiveColor = INACTIVE_COLOR, activeColor = ACTIVE_COLOR, currentColor = CURRENT_COLOR, dotSize = DOT_SIZE, borderRadius = BORDER_RADIUS,}: { index: number; animation: SharedValue<number>; inactiveColor?: string; activeColor?: string; currentColor?: string; dotSize?: number; borderRadius?: number; dotContainer?: number;}) { const animatedDotContainerStyle = useAnimatedStyle(() => { return { backgroundColor: interpolateColor( animation.value, [index - 1, index, index + 1], [inactiveColor, activeColor, currentColor], ), }; }); return ( <Animated.View style={styles.dotContainer}> <Animated.View style={[ styles.dot, { width: dotSize, height: dotSize, borderRadius: borderRadius, }, animatedDotContainerStyle, ]} /> </Animated.View> );}const styles = StyleSheet.create({ dotContainer: { width: DOT_CONTAINER, height: DOT_CONTAINER, justifyContent: "center", alignItems: "center", }, container: { flexDirection: "row", justifyContent: "center", alignItems: "center", }, dot: { width: 20, height: 10, backgroundColor: "#000", marginHorizontal: 5, },});Usage
import { View, Text, StyleSheet, Image, Dimensions } from "react-native";import { GestureHandlerRootView } from "react-native-gesture-handler";import { StatusBar } from "expo-status-bar";import { useFonts } from "expo-font";import { SymbolView } from "expo-symbols";import { useState } from "react";import { CircularCarousel } from "@/components/molecules/circular-carousel";import { LinearGradient } from "expo-linear-gradient";import MaterialCarousel from "@/components/molecules/material-carousel";import { MorphicTabBar } from "@/components/molecules/morphing-tabbar";import { Pagination } from "@/components";const { width: SCREEN_WIDTH } = Dimensions.get("window");export default function App() { const [fontLoaded] = useFonts({ SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"), HelveticaNowDisplay: require("@/assets/fonts/HelveticaNowDisplayMedium.ttf"), StretchPro: require("@/assets/fonts/StretchPro.otf"), }); const [currentIndex, setCurrentIndex] = useState<number>(0); const DATA = [ { id: "1", name: "MY DEAR MELANCHOLY", artist: "The Weeknd", year: "2018", image: "https://i.pinimg.com/1200x/18/e6/e8/18e6e8e2d2b8c5b4dd77a4ae705bf96a.jpg", }, { id: "2", name: "RANDOM ACCESS MEMORIES", artist: "Daft Punk", year: "2013", image: "https://i.pinimg.com/1200x/91/52/b2/9152b2dc174934279cda4509b0931434.jpg", }, { id: "3", name: "CURRENTS", artist: "Tame Impala", year: "2015", image: "https://i.pinimg.com/1200x/1e/38/7f/1e387f131098067f7a9be0bc68b0b6f2.jpg", }, { id: "4", name: "PLASTIC BEACH", artist: "Gorillaz", year: "2010", image: "https://i.pinimg.com/736x/43/e0/e0/43e0e0a542c0ccfbc5cf1b802bcf2d66.jpg", }, ]; const ITEMS: string[] = [ "https://i.pinimg.com/736x/74/d1/83/74d183cb89a6b10bd96203322e0d5512.jpg", "https://i.pinimg.com/736x/7a/52/bc/7a52bc56851dc1a16233308076658e47.jpg", "https://i.pinimg.com/1200x/31/46/be/3146be20950b9567fd38eb2a5bd00572.jpg", "https://i.pinimg.com/736x/cb/b2/b7/cbb2b7fc14c96fdb5916c82fa9fd555e.jpg", ]; return ( <GestureHandlerRootView style={styles.container}> <StatusBar style="inverted" /> <View style={styles.header}> {/* <View> <Text style={[styles.title, fontLoaded && { fontFamily: "StretchPro" }]} > GALLARY </Text> <Text style={[ styles.subtitle, fontLoaded && { fontFamily: "HelveticaNowDisplay" }, ]} > Explore your recent favorites. </Text> </View> <View style={styles.headerRight}> <SymbolView name="square.stack.3d.up.fill" size={20} tintColor="#fff" /> </View> */} <Pagination activeIndex={0} dotSize={8} inactiveColor="#3f3f46" activeColor="#f4f4f5" currentColor="#f4f4f5" borderRadius={4} dotContainer={4} containerStyle={{ backgroundColor: "transparent", padding: 8, }} totalItems={5} /> </View> </GestureHandlerRootView> );}const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#0a0a0a", }, header: { flexDirection: "row", alignItems: "center", justifyContent: "center", paddingTop: 70, paddingBottom: 32, }, title: { fontSize: 28, color: "#fff", letterSpacing: 2, }, subtitle: { fontSize: 12, color: "#aaa", }, headerRight: { width: 40, height: 40, borderRadius: 20, backgroundColor: "#1a1a1a", justifyContent: "center", alignItems: "center", }, card: { width: "100%", height: 340, borderRadius: 24, overflow: "hidden", }, cardImage: { width: 300, height: "100%", resizeMode: "cover", }, cardGradient: { ...StyleSheet.absoluteFillObject, }, cardContent: { position: "absolute", bottom: 0, left: 0, right: 0, padding: 20, gap: 8, }, albumName: { fontSize: 16, color: "#fff", letterSpacing: 1, }, artistRow: { flexDirection: "row", alignItems: "center", gap: 6, }, artistText: { fontSize: 13, color: "rgba(255,255,255,0.7)", }, dot: { width: 3, height: 3, borderRadius: 1.5, backgroundColor: "rgba(255,255,255,0.4)", }, yearText: { fontSize: 13, color: "rgba(255,255,255,0.5)", }, footer: { paddingHorizontal: 24, marginTop: 32, gap: 20, }, nowPlaying: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", backgroundColor: "#141414", padding: 12, borderRadius: 16, }, nowPlayingLeft: { flexDirection: "row", alignItems: "center", gap: 12, flex: 1, }, nowPlayingImage: { width: 48, height: 48, borderRadius: 10, }, nowPlayingInfo: { flex: 1, gap: 2, }, nowPlayingTitle: { fontSize: 14, fontWeight: "600", color: "#fff", }, nowPlayingArtist: { fontSize: 12, color: "#666", }, nowPlayingControls: { width: 44, height: 44, borderRadius: 22, backgroundColor: "#fff", justifyContent: "center", alignItems: "center", }, dots: { flexDirection: "row", justifyContent: "center", alignItems: "center", gap: 6, }, dotIndicator: { width: 6, height: 6, borderRadius: 3, backgroundColor: "#333", }, dotIndicatorActive: { width: 20, backgroundColor: "#fff", },});Props
React Native Reanimated
React Native Worklets
Expo Haptics
React Native Gesture Handler
