Skip to content
pixelfor Expo
Ctrl K

Navigation

Bounce Sidebar

A selection dot follows a spring arc between navigation rows

Try it on your phone

Bounce Sidebar

Scan to open Bounce Sidebar 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

Be UI

Our Expo component

React Native Web

Component 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.

Back to Be UI