Navigation
Bounce Sidebar
A selection dot follows a spring arc between navigation rows
Expo code
Original web component
Be UIOur Expo component
React Native WebComponent source
Make it yours
One file to copy into your app.
bounce-sidebar.tsx
import React, { useEffect, useRef, useState } from "react"
import { Linking, Platform, Pressable, Text, View } from "react-native"
import Animated, {
useAnimatedStyle,
useReducedMotion,
useSharedValue,
withSpring,
} from "react-native-reanimated"
export interface BounceSidebarItem {
id: string
label: React.ReactNode
icon?: React.ReactNode
href?: string
disabled?: boolean
}
export interface BounceSidebarProps {
items: BounceSidebarItem[]
value?: string
defaultValue?: string
onValueChange?: (value: string) => void
backgroundColor?: string
textColor?: string
mutedColor?: string
indicatorColor?: string
rowHeight?: number
fontSize?: number
ariaLabel?: string
testID?: string
}
export function BounceSidebar({
items,
value,
defaultValue,
onValueChange,
backgroundColor = "transparent",
textColor = "#f1f1f1",
mutedColor = "#868686",
indicatorColor = "#0285f7",
rowHeight = 36,
fontSize = 14,
ariaLabel = "Navigation",
testID,
}: BounceSidebarProps) {
const [local, setLocal] = useState(defaultValue ?? items[0]?.id)
const selected = value ?? local
const reduced = useReducedMotion()
const centers = useRef<Record<string, number>>({})
const from = useSharedValue(0),
to = useSharedValue(0),
control = useSharedValue(0),
travel = useSharedValue(1)
const [measured, setMeasured] = useState(0)
useEffect(() => {
const next = centers.current[selected ?? ""]
if (next === undefined) return
const p = travel.value
from.value = (1-p)*(1-p)*from.value + 2*(1-p)*p*control.value + p*p*to.value
to.value = next
const jump = Math.min(1, Math.max(0, (Math.abs(next-from.value)-48)/120))
control.value = next + ((from.value+next)/2-next)*jump
travel.value = 0
travel.value = reduced
? 1
: withSpring(1, { stiffness: 280-60*jump, damping: 18+jump, mass: 0.3+0.15*jump })
}, [selected, measured, reduced, from, to, travel])
const dot = useAnimatedStyle(() => {
const p = travel.value,
distance = Math.abs(to.value - from.value)
return {
transform: [
{ translateY: (1-p)*(1-p)*from.value + 2*(1-p)*p*control.value + p*p*to.value - 3 },
{ translateX: -2 * Math.min(40, Math.max(8, distance * 0.25)) * p * (1 - p) },
],
}
})
return (
<View
testID={testID}
accessibilityLabel={ariaLabel}
style={{
paddingLeft: 24,
borderRadius: 20,
backgroundColor,
}}
>
<Animated.View
pointerEvents="none"
style={[
{
position: "absolute",
left: 12,
top: 0,
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: indicatorColor,
},
dot,
]}
/>
{items.map((item, index) => (
<Pressable
key={item.id}
disabled={item.disabled}
accessibilityRole={item.href ? "link" : "button"}
accessibilityState={{
selected: selected === item.id,
disabled: item.disabled,
}}
onLayout={({ nativeEvent: { layout } }) => {
centers.current[item.id] = layout.y + layout.height / 2
setMeasured((n) => n + 1)
}}
onPress={() => {
if (value === undefined) setLocal(item.id)
onValueChange?.(item.id)
if (item.href) void Linking.openURL(item.href)
}}
style={({ pressed }) => ({
minHeight: rowHeight,
marginBottom: index === items.length-1 ? 0 : 6,
paddingHorizontal: 8,
flexDirection: "row",
alignItems: "center",
gap: 8,
opacity: item.disabled ? 0.35 : pressed ? 0.7 : 1,
})}
>
{item.icon}
<Text
style={{
fontSize,
fontWeight: "500",
fontFamily: Platform.OS === "web" ? '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif' : undefined,
color: selected === item.id ? textColor : mutedColor,
}}
>
{item.label}
</Text>
</Pressable>
))}
</View>
)
}
Give the gestures and motion a run on a real Android phone.