Accordion
Bouncy accordion
Connected rows that open with a little spring in their step
Expo code
Original web component
Be UIOur Expo component
React Native WebComponent 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.