Skip to content
pixelfor Expo
Ctrl K

Accordion

Bouncy accordion

Connected rows that open with a little spring in their step

Try it on your phone

Bouncy accordion

Scan to open Bouncy accordion 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.

bouncy-accordion.tsx

import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react"
import {
  Pressable,
  StyleSheet,
  Text,
  View,
  type StyleProp,
  type TextStyle,
  type ViewStyle,
} from "react-native"
import Animated, {
  Easing,
  ReduceMotion,
  useAnimatedStyle,
  useSharedValue,
  withSpring,
  withTiming,
} from "react-native-reanimated"
export type BouncyAccordionItem = {
  id: string
  title: ReactNode
  description?: ReactNode
  icon?: ReactNode
  disabled?: boolean
  accessibilityLabel?: string
}
export interface BouncyAccordionProps {
  items: BouncyAccordionItem[]
  value?: string | null
  defaultValue?: string | null
  onValueChange?: (value: string | null) => void
  collapsible?: boolean
  backgroundColor?: string
  textColor?: string
  mutedColor?: string
  pressedColor?: string
  radius?: number
  gap?: number
  rowHeight?: number
  horizontalPadding?: number
  fontSize?: number
  lineHeight?: number
  reduceMotion?: boolean
  style?: StyleProp<ViewStyle>
  titleStyle?: StyleProp<TextStyle>
  descriptionStyle?: StyleProp<TextStyle>
  testID?: string
}
const ROW = { duration: 550, dampingRatio: 0.62 }
const OPEN = { duration: 580, dampingRatio: 0.68 }
const CLOSE = { duration: 460, dampingRatio: 0.74 }
const CHEVRON = { duration: 420, dampingRatio: 0.72 }
type RowProps = Required<
  Pick<
    BouncyAccordionProps,
    | "backgroundColor"
    | "textColor"
    | "mutedColor"
    | "pressedColor"
    | "radius"
    | "gap"
    | "rowHeight"
    | "horizontalPadding"
    | "fontSize"
    | "lineHeight"
  >
> &
  Pick<BouncyAccordionProps, "titleStyle" | "descriptionStyle" | "testID"> & {
    item: BouncyAccordionItem
    open: boolean
    startsGroup: boolean
    endsGroup: boolean
    separated: boolean
    motion: ReduceMotion
    targetY: number
    onContentHeight: (height: number) => void
    onToggle: () => void
  }
function AccordionRow({
  item,
  open,
  startsGroup,
  endsGroup,
  separated,
  motion,
  targetY,
  onContentHeight,
  onToggle,
  backgroundColor,
  textColor,
  mutedColor,
  pressedColor,
  radius,
  gap,
  rowHeight,
  horizontalPadding,
  fontSize,
  lineHeight,
  titleStyle,
  descriptionStyle,
  testID,
}: RowProps) {
  const [contentHeight, setContentHeight] = useState(0)
  const reveal = useSharedValue(0)
  const opacity = useSharedValue(open ? 1 : 0)
  const position = useSharedValue(0)
  const previousY = useRef(targetY)
  const topRadius = useSharedValue(startsGroup ? radius : 0)
  const bottomRadius = useSharedValue(endsGroup ? radius : 0)
  const rotation = useSharedValue(open ? 180 : 0)
  useLayoutEffect(() => {
    if (previousY.current !== targetY) {
      position.set(position.get() + previousY.current - targetY)
      position.set(withSpring(0, { ...ROW, reduceMotion: motion }))
    }
    previousY.current = targetY
  }, [targetY, motion, position])
  useEffect(() => {
    reveal.set(
      withSpring(open && item.description != null ? contentHeight : 0, {
        ...(open ? OPEN : CLOSE),
        reduceMotion: motion,
      })
    )
    opacity.set(
      withTiming(open ? 1 : 0, {
        duration: 180,
        easing: Easing.out(Easing.cubic),
        reduceMotion: motion,
      })
    )
    rotation.set(
      withSpring(open ? 180 : 0, { ...CHEVRON, reduceMotion: motion })
    )
  }, [open, contentHeight, item.description, motion, reveal, opacity, rotation])
  useEffect(() => {
    topRadius.set(
      withSpring(startsGroup ? radius : 0, { ...ROW, reduceMotion: motion })
    )
    bottomRadius.set(
      withSpring(endsGroup ? radius : 0, { ...ROW, reduceMotion: motion })
    )
  }, [
    startsGroup,
    endsGroup,
    radius,
    motion,
    topRadius,
    bottomRadius,
  ])
  const positionStyle = useAnimatedStyle(() => ({
    transform: [{ translateY: position.get() }],
  }))
  const cardStyle = useAnimatedStyle(() => ({
    borderTopLeftRadius: Math.max(0, topRadius.get()),
    borderTopRightRadius: Math.max(0, topRadius.get()),
    borderBottomLeftRadius: Math.max(0, bottomRadius.get()),
    borderBottomRightRadius: Math.max(0, bottomRadius.get()),
  }))
  const contentStyle = useAnimatedStyle(() => ({
    height: Math.max(0, reveal.get()),
  }))
  const descriptionAnimation = useAnimatedStyle(() => ({
    opacity: opacity.get(),
  }))
  const chevronStyle = useAnimatedStyle(() => ({
    transform: [{ rotate: `${rotation.get()}deg` }],
  }))
  const isText = (node: ReactNode) =>
    typeof node === "string" || typeof node === "number"
  return (
    <View
      style={{ marginTop: separated ? gap : 0, height: Math.max(48, rowHeight) + (open && item.description != null ? contentHeight : 0) }}
    >
      <Animated.View
        style={[
          styles.card,
          { backgroundColor, opacity: item.disabled ? 0.5 : 1 },
          cardStyle,
          positionStyle,
        ]}
      >
        <Pressable
          testID={testID ? `${testID}-${item.id}` : undefined}
          accessibilityRole="button"
          accessibilityLabel={
            item.accessibilityLabel ??
            (isText(item.title) ? String(item.title) : undefined)
          }
          accessibilityState={{ expanded: open, disabled: !!item.disabled }}
          aria-expanded={open}
          aria-disabled={!!item.disabled}
          disabled={item.disabled}
          onPress={onToggle}
          style={({ pressed }) => [
            styles.trigger,
            {
              minHeight: Math.max(48, rowHeight),
              paddingHorizontal: horizontalPadding,
              backgroundColor: pressed ? pressedColor : "transparent",
            },
          ]}
        >
          {item.icon != null && <View style={styles.icon}>{item.icon}</View>}
          {isText(item.title) ? (
            <Text
              numberOfLines={1}
              style={[styles.title, { color: textColor, fontSize }, titleStyle]}
            >
              {item.title}
            </Text>
          ) : (
            <View style={styles.title}>{item.title}</View>
          )}
          <Animated.View
            pointerEvents="none"
            accessible={false}
            style={[styles.chevronBox, chevronStyle]}
          >
            <View style={[styles.chevron, { borderColor: mutedColor }]} />
          </Animated.View>
        </Pressable>
        <Animated.View
          pointerEvents={open && !item.disabled ? "auto" : "none"}
          accessibilityElementsHidden={!open}
          aria-hidden={!open}
          importantForAccessibility={open ? "auto" : "no-hide-descendants"}
          style={[styles.clip, contentStyle]}
        >
          <Animated.View
            onLayout={({ nativeEvent }) => {
              const height = nativeEvent.layout.height
              setContentHeight((previous) =>
                Math.abs(previous - height) < 0.5 ? previous : height
              )
              onContentHeight(height)
            }}
            style={[
              styles.description,
              {
                paddingHorizontal: horizontalPadding,
                paddingBottom: horizontalPadding,
              },
              descriptionAnimation,
            ]}
          >
            {isText(item.description) ? (
              <Text
                style={[
                  { color: mutedColor, fontSize, lineHeight },
                  descriptionStyle,
                ]}
              >
                {item.description}
              </Text>
            ) : (
              item.description
            )}
          </Animated.View>
        </Animated.View>
      </Animated.View>
    </View>
  )
}
export function BouncyAccordion({
  items,
  value,
  defaultValue = null,
  onValueChange,
  collapsible = true,
  backgroundColor = "#f4f4f5",
  textColor = "#18181b",
  mutedColor = "#71717a",
  pressedColor = "rgba(0,0,0,0.035)",
  radius = 28,
  gap = 12,
  rowHeight = 54,
  horizontalPadding = 20,
  fontSize = 15,
  lineHeight = 24,
  reduceMotion,
  style,
  titleStyle,
  descriptionStyle,
  testID,
}: BouncyAccordionProps) {
  const [internalValue, setInternalValue] = useState(defaultValue)
  const [contentHeights, setContentHeights] = useState<Record<string, number>>({})
  const current = value !== undefined ? value : internalValue
  const activeIndex = items.findIndex((item) => item.id === current)
  const motion =
    reduceMotion === undefined
      ? ReduceMotion.System
      : reduceMotion
        ? ReduceMotion.Always
        : ReduceMotion.Never
  const toggle = (id: string) => {
    if (current === id && !collapsible) return
    const next = current === id ? null : id
    if (value === undefined) setInternalValue(next)
    onValueChange?.(next)
  }
  let targetY = 0
  return (
    <View testID={testID} style={style}>
      {items.map((item, index) => {
        const open = current === item.id
        const separated = index > 0 && (open || activeIndex === index - 1)
        const rowY = targetY + (separated ? gap : 0)
        targetY = rowY + Math.max(48, rowHeight) + (open ? contentHeights[item.id] ?? 0 : 0)
        return (
        <AccordionRow
          key={item.id}
          item={item}
          open={open}
          targetY={rowY}
          onContentHeight={(height) => setContentHeights(previous =>
            Math.abs((previous[item.id] ?? 0) - height) < 0.5 ? previous : { ...previous, [item.id]: height }
          )}
          startsGroup={
            current === item.id || index === 0 || activeIndex === index - 1
          }
          endsGroup={
            current === item.id ||
            index === items.length - 1 ||
            activeIndex === index + 1
          }
          separated={separated}
          onToggle={() => toggle(item.id)}
          motion={motion}
          {...{
            backgroundColor,
            textColor,
            mutedColor,
            pressedColor,
            radius,
            gap,
            rowHeight,
            horizontalPadding,
            fontSize,
            lineHeight,
            titleStyle,
            descriptionStyle,
            testID,
          }}
        />
      )})}
    </View>
  )
}
const styles = StyleSheet.create({
  card: { overflow: "hidden" },
  trigger: { flexDirection: "row", alignItems: "center", gap: 16 },
  icon: {
    width: 28,
    height: 28,
    alignItems: "center",
    justifyContent: "center",
  },
  title: { flex: 1, minWidth: 0, fontWeight: "500" },
  chevronBox: {
    width: 24,
    height: 24,
    alignItems: "center",
    justifyContent: "center",
  },
  chevron: {
    width: 9,
    height: 9,
    borderRightWidth: 1.5,
    borderBottomWidth: 1.5,
    transform: [{ translateY: -2 }, { rotate: "45deg" }],
  },
  clip: { overflow: "hidden" },
  description: { position: "absolute", top: 0, left: 0, right: 0 },
})

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

Back to Be UI