Skip to content
pixelfor Expo
Ctrl K

Navigation

Carousel Navigator

Expanding progress indicators and color-changing arrows keep a carousel moving

Try it on your phone

Carousel Navigator

Scan to open Carousel Navigator in Expo Go

Install Expo Go first, then scan this QR to run the native component.

GET EXPO GO ONGoogle Play

This demo uses Expo SDK 54. If your installed Expo Go supports a different SDK, get the compatible build. iOS needs a build supporting this SDK.

Expo code

Original web component

Watermelon UI

Our Expo component

React Native Web

Component source

Make it yours

One file to copy into your app.

carousel-navigator.tsx

import React, { useEffect } from "react"
import { Pressable, View, type StyleProp, type ViewStyle } from "react-native"
import Animated, {
  Easing, cancelAnimation, useAnimatedStyle, useReducedMotion,
  useSharedValue, withSpring, withTiming, type SharedValue,
} from "react-native-reanimated"
import Svg, { Path } from "react-native-svg"
export type CarouselNavigatorTheme = {
  bg: string
  button: string
  dot: string
  progress: string
}
export interface CarouselNavigatorProps {
  totalSlides?: number
  autoDelay?: number
  themes?: CarouselNavigatorTheme[]
  currentIndex: number
  onIndexChange: (index: number) => void
  style?: StyleProp<ViewStyle>
  testID?: string
}
const DEFAULT_THEMES: CarouselNavigatorTheme[] = [
  { bg: "#f4f4f5", button: "#18181b", dot: "#d4d4d8", progress: "#d4d4d8" },
  { bg: "#dbeafe", button: "#155dfc", dot: "#8ec5ff", progress: "#8ec5ff" },
  { bg: "#dcfce7", button: "#00a63e", dot: "#05df72", progress: "#05df72" },
  { bg: "#fef9c2", button: "#fdc700", dot: "#ffdf20", progress: "#ffdf20" },
]
const SPRING = { stiffness: 300, damping: 30, mass: 1 }
const COLOR = { duration: 300, easing: Easing.bezier(0.4, 0, 0.2, 1) }
const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
function Arrow({ direction, muted, color, onPress, reduced }: {
  direction: "previous" | "next"
  muted?: boolean
  color: string
  onPress: () => void
  reduced: boolean
}) {
  const scale = useSharedValue(1)
  const background = useSharedValue(muted ? "#d1d5dc" : color)
  const opacity = useSharedValue(muted ? 0.5 : 1)
  useEffect(() => {
    background.set(withTiming(muted ? "#d1d5dc" : color, COLOR))
    opacity.set(muted ? 0.5 : 1)
  }, [color, muted, background, opacity])
  const animated = useAnimatedStyle(() => ({
    backgroundColor: background.get(), opacity: opacity.get(),
    transform: [{ scale: scale.get() }],
  }))
  return <AnimatedPressable
    accessibilityRole="button"
    accessibilityLabel={direction === "previous" ? "Previous slide" : "Next slide"}
    onPress={onPress}
    onPressIn={() => scale.set(reduced ? 1 : withSpring(0.9, { stiffness: 500, damping: 25 }))}
    onPressOut={() => scale.set(withSpring(1, { stiffness: 500, damping: 25 }))}
    style={[{
      width: 48, height: 48, borderRadius: 24, alignItems: "center", justifyContent: "center",
      boxShadow: "0px 1px 3px rgba(0,0,0,0.1)",
    }, animated]}
  >
    <Svg width={24} height={24} viewBox="0 0 24 24" fill="none">
      <Path d={direction === "previous" ? "m15 18-6-6 6-6" : "m9 18 6-6-6-6"}
        stroke="#fff" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" />
    </Svg>
  </AnimatedPressable>
}
function Indicator({ index, expansions, active, theme, autoDelay, onPress }: {
  index: number
  expansions: SharedValue<number[]>
  active: boolean
  theme: CarouselNavigatorTheme
  autoDelay: number
  onPress: () => void
}) {
  const progress = useSharedValue(0)
  const color = useSharedValue(active ? theme.progress : theme.dot)
  useEffect(() => {
    cancelAnimation(progress)
    progress.set(0)
    if (active) progress.set(withTiming(1, { duration: autoDelay, easing: Easing.linear }))
    return () => cancelAnimation(progress)
  }, [active, autoDelay, progress])
  useEffect(() => {
    color.set(withTiming(active ? theme.progress : theme.dot, COLOR))
  }, [active, theme.dot, theme.progress, color])
  const geometry = useAnimatedStyle(() => {
    const widths = expansions.get()
    const expansion = widths[index]
    let preceding = 0
    for (let i = 0; i < index; i++) preceding += widths[i]
    return {
      width: 12 + expansion,
      backgroundColor: color.get(),
      transform: [{ translateX: index * 20 + preceding }],
    }
  })
  const fill = useAnimatedStyle(() => {
    const expansion = expansions.get()[index]
    return { width: (12 + expansion) * progress.get() }
  })
  return <AnimatedPressable
    accessibilityRole="button"
    accessibilityLabel={`Go to slide ${index + 1}`}
    accessibilityState={{ selected: active }}
    onPress={onPress}
    hitSlop={{ top: 16, bottom: 16, left: 4, right: 4 }}
    style={[{ position: "absolute", left: 8, top: 0, height: 12, borderRadius: 24 }, geometry]}
  >
    {active && <Animated.View pointerEvents="none" style={[{
      position: "absolute", left: 0, top: 0, height: 12, borderRadius: 24,
      backgroundColor: "#fff", boxShadow: "0px 0px 8px rgba(255,255,255,0.5)",
    }, fill]} />}
  </AnimatedPressable>
}
export function CarouselNavigator({
  totalSlides = 4, autoDelay = 5000, themes = DEFAULT_THEMES,
  currentIndex, onIndexChange, style, testID,
}: CarouselNavigatorProps) {
  const reduced = useReducedMotion()
  const expansions = useSharedValue<number[]>(Array.from({ length: totalSlides }, (_, i) => i === currentIndex ? 36 : 0))
  const theme = themes[currentIndex % themes.length] ?? DEFAULT_THEMES[0]
  const background = useSharedValue(theme.bg)
  useEffect(() => {
    const target = Array.from({ length: totalSlides }, (_, i) => i === currentIndex ? 36 : 0)
    expansions.set(reduced ? target : withSpring(target, SPRING))
    background.set(withTiming(theme.bg, COLOR))
  }, [currentIndex, totalSlides, reduced, theme.bg, expansions, background])
  const surface = useAnimatedStyle(() => ({ backgroundColor: background.get() }))
  if (totalSlides < 1) return null
  return <Animated.View testID={testID} style={[{
    alignSelf: "center", flexDirection: "row", alignItems: "center", justifyContent: "center",
    gap: 4, borderRadius: 999, paddingHorizontal: 16, paddingVertical: 12,
  }, surface, style]}>
    <Arrow direction="previous" muted={currentIndex === 0} color={theme.button}
      reduced={reduced} onPress={() => onIndexChange((currentIndex - 1 + totalSlides) % totalSlides)} />
    <View style={{ width: totalSlides * 12 + 36 + Math.max(0, totalSlides - 1) * 8 + 16, height: 12 }}>
      {Array.from({ length: totalSlides }, (_, index) => <Indicator key={index}
        index={index} expansions={expansions} active={index === currentIndex} theme={theme}
        autoDelay={autoDelay} onPress={() => onIndexChange(index)} />)}
    </View>
    <Arrow direction="next" color={theme.button} reduced={reduced}
      onPress={() => onIndexChange((currentIndex + 1) % totalSlides)} />
  </Animated.View>
}
export default CarouselNavigator

Give the gestures and motion a run on a real Android phone.

Back to Watermelon UI