Navigation
Carousel Navigator
Expanding progress indicators and color-changing arrows keep a carousel moving
Expo code
Original web component
Watermelon UIOur Expo component
React Native WebComponent 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.