Accordion
Vertically stacked sections that expand and collapse with a measured-height Reanimated animation.
Prerequisites
- React Native
>= 0.86.0· New Architecture only- npm packages
- react-native-reanimated >=4.0.0
- react-native-worklets >=0.5.0
- Registry
- theme — copied once, see Installation
Installation
npx pocketui add accordionComing soon1. Copy into
components/ui/accordion.tsxcomponents/ui/accordion.tsx import { type Context, createContext, type ReactNode, use, useEffect, useState } from 'react'; import { Pressable, type StyleProp, StyleSheet, Text, View, type ViewStyle } from 'react-native'; import Animated, { type SharedValue, useAnimatedStyle, useSharedValue, withTiming, } from 'react-native-reanimated'; import { scheduleOnRN } from 'react-native-worklets'; import { theme, useTheme } from './theme'; const DURATION = 220; interface AccordionBaseProps { style?: StyleProp<ViewStyle>; children: ReactNode; } export interface AccordionSingleProps extends AccordionBaseProps { type: 'single'; value?: string; defaultValue?: string; onValueChange?: (value: string | undefined) => void; /** Allow closing the open item by pressing it again. */ collapsible?: boolean; } export interface AccordionMultipleProps extends AccordionBaseProps { type: 'multiple'; value?: string[]; defaultValue?: string[]; onValueChange?: (value: string[]) => void; } export type AccordionProps = AccordionSingleProps | AccordionMultipleProps; interface AccordionContextValue { openValues: readonly string[]; toggle: (value: string) => void; } interface ItemContextValue { value: string; open: boolean; mounted: boolean; disabled: boolean; progress: SharedValue<number>; } const AccordionContext = createContext<AccordionContextValue | null>(null); const ItemContext = createContext<ItemContextValue | null>(null); function useRequiredContext<TValue>( context: Context<TValue | null>, component: string, parent: string, ): TValue { const value = use(context); if (value === null) throw new Error(`<${component}> must be used within <${parent}>`); return value; } function toList(value: string | readonly string[] | undefined): string[] { if (value === undefined) return []; return typeof value === 'string' ? [value] : [...value]; } /** * Vertically stacked, collapsible sections. Controlled when `value` is passed * (for `type="single"`, pass `value={undefined}` to keep it controlled and closed). */ export function Accordion(props: AccordionProps) { const controlled = 'value' in props; const [uncontrolled, setUncontrolled] = useState(() => toList(props.defaultValue)); const openValues = controlled ? toList(props.value) : uncontrolled; const toggle = (item: string) => { const isOpen = openValues.includes(item); if (props.type === 'multiple') { const next = isOpen ? openValues.filter((v) => v !== item) : [...openValues, item]; if (!controlled) setUncontrolled(next); props.onValueChange?.(next); return; } if (isOpen && !props.collapsible) return; const next = isOpen ? undefined : item; if (!controlled) setUncontrolled(toList(next)); props.onValueChange?.(next); }; return ( <AccordionContext value={{ openValues, toggle }}> <View style={props.style}>{props.children}</View> </AccordionContext> ); } export interface AccordionItemProps { value: string; disabled?: boolean; style?: StyleProp<ViewStyle>; children: ReactNode; } export function AccordionItem({ value, disabled = false, style, children }: AccordionItemProps) { const { openValues } = useRequiredContext(AccordionContext, 'AccordionItem', 'Accordion'); const { colors } = useTheme(); const open = openValues.includes(value); const progress = useSharedValue(open ? 1 : 0); const [mounted, setMounted] = useState(open); if (open && !mounted) setMounted(true); useEffect(() => { progress.set( withTiming(open ? 1 : 0, { duration: DURATION }, (finished) => { 'worklet'; if (finished && !open) scheduleOnRN(setMounted, false); }), ); }, [open, progress]); return ( <ItemContext value={{ value, open, mounted, disabled, progress }}> <View style={[styles.item, { borderBottomColor: colors.border }, style]}>{children}</View> </ItemContext> ); } export interface AccordionTriggerProps { children: ReactNode; style?: StyleProp<ViewStyle>; testID?: string; } export function AccordionTrigger({ children, style, testID }: AccordionTriggerProps) { const { toggle } = useRequiredContext(AccordionContext, 'AccordionTrigger', 'Accordion'); const { value, open, disabled, progress } = useRequiredContext( ItemContext, 'AccordionTrigger', 'AccordionItem', ); const { colors } = useTheme(); const chevronStyle = useAnimatedStyle(() => ({ transform: [{ rotate: `${progress.value * 180}deg` }], })); return ( <Pressable accessibilityRole="button" accessibilityState={{ expanded: open, disabled }} disabled={disabled} onPress={() => toggle(value)} testID={testID} style={({ pressed }) => [ styles.trigger, pressed && styles.pressed, disabled && styles.disabled, style, ]} > {typeof children === 'string' ? ( <Text style={[styles.triggerText, { color: colors.foreground }]}>{children}</Text> ) : ( children )} <Animated.View style={[styles.chevron, chevronStyle]}> <View style={[styles.chevronIcon, { borderColor: colors.mutedForeground }]} /> </Animated.View> </Pressable> ); } export interface AccordionContentProps { children: ReactNode; style?: StyleProp<ViewStyle>; testID?: string; } export function AccordionContent({ children, style, testID }: AccordionContentProps) { const { open, mounted, progress } = useRequiredContext( ItemContext, 'AccordionContent', 'AccordionItem', ); const { colors } = useTheme(); const height = useSharedValue(0); const animatedStyle = useAnimatedStyle(() => ({ height: height.value * progress.value })); if (!mounted) return null; return ( <Animated.View testID={testID} accessibilityElementsHidden={!open} importantForAccessibility={open ? 'auto' : 'no-hide-descendants'} style={[styles.contentClip, animatedStyle]} > <View style={styles.contentMeasure} onLayout={(event) => height.set(event.nativeEvent.layout.height)} > <View style={[styles.content, style]}> {typeof children === 'string' ? ( <Text style={[styles.contentText, { color: colors.foreground }]}>{children}</Text> ) : ( children )} </View> </View> </Animated.View> ); } const styles = StyleSheet.create({ item: { borderBottomWidth: StyleSheet.hairlineWidth, }, trigger: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: theme.spacing.md, paddingVertical: theme.spacing.lg, }, pressed: { opacity: 0.7, }, disabled: { opacity: 0.5, }, triggerText: { flex: 1, fontSize: theme.fontSize.md, fontWeight: '500', }, chevron: { width: 16, height: 16, alignItems: 'center', justifyContent: 'center', }, chevronIcon: { width: 8, height: 8, marginTop: -4, borderRightWidth: 1.5, borderBottomWidth: 1.5, transform: [{ rotate: '45deg' }], }, contentClip: { overflow: 'hidden', }, contentMeasure: { position: 'absolute', top: 0, left: 0, right: 0, }, content: { paddingBottom: theme.spacing.lg, }, contentText: { fontSize: theme.fontSize.sm, lineHeight: 20, }, });
Usage
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
export function Faq() {
return (
<Accordion type="single" collapsible>
<AccordionItem value="item-1">
<AccordionTrigger>Is it accessible?</AccordionTrigger>
<AccordionContent>Yes. Each trigger is a button with an expanded state.</AccordionContent>
</AccordionItem>
</Accordion>
);
}type="single" keeps at most one item open; add collapsible to let the open item close. type="multiple" lets several items stay open. Pass value and onValueChange to control it, or defaultValue to let it manage itself.
Full demo
The full demo screen from the registry, including a type="multiple" accordion.
import { StyleSheet, Text, View } from 'react-native';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '../ui/accordion';
import { theme, useTheme } from '../ui/theme';
export default function AccordionDemo() {
const { colors } = useTheme();
const paragraph = [styles.paragraph, { color: colors.foreground }];
const heading = [styles.heading, { color: colors.mutedForeground }];
return (
<View style={styles.container}>
<Text style={heading}>Single</Text>
<Accordion type="single" collapsible>
<AccordionItem value="item-1">
<AccordionTrigger testID="accordion-trigger-item-1">Is it accessible?</AccordionTrigger>
<AccordionContent testID="accordion-content-item-1">
Yes. Each trigger is a button that reports its expanded state to screen readers.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger testID="accordion-trigger-item-2">Is it animated?</AccordionTrigger>
<AccordionContent testID="accordion-content-item-2">
Yes. Height and chevron animate on the UI thread with Reanimated.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-3">
<AccordionTrigger testID="accordion-trigger-item-3">
Does it fit long content?
</AccordionTrigger>
<AccordionContent testID="accordion-content-item-3">
<View style={styles.paragraphs}>
<Text style={paragraph}>
Content is measured before it animates, so panels of any height open fully instead
of clipping.
</Text>
<Text style={paragraph}>
Text that wraps across several lines, or content that changes after mount, is
re-measured automatically.
</Text>
<Text style={paragraph}>Last line of item three.</Text>
</View>
</AccordionContent>
</AccordionItem>
</Accordion>
<Text style={heading}>Multiple</Text>
<Accordion type="multiple" defaultValue={['multi-1']}>
<AccordionItem value="multi-1">
<AccordionTrigger testID="accordion-trigger-multi-1">
Can several stay open?
</AccordionTrigger>
<AccordionContent testID="accordion-content-multi-1">
Yes. With type="multiple", opening one item leaves the others as they are.
</AccordionContent>
</AccordionItem>
<AccordionItem value="multi-2">
<AccordionTrigger testID="accordion-trigger-multi-2">
Can it be controlled?
</AccordionTrigger>
<AccordionContent testID="accordion-content-multi-2">
Yes. Pass value and onValueChange to control it, or defaultValue to let it manage
itself.
</AccordionContent>
</AccordionItem>
</Accordion>
</View>
);
}
const styles = StyleSheet.create({
container: {
gap: theme.spacing.sm,
padding: theme.spacing.lg,
},
heading: {
fontSize: theme.fontSize.sm,
fontWeight: '600',
marginTop: theme.spacing.lg,
},
paragraphs: {
gap: theme.spacing.sm,
},
paragraph: {
fontSize: theme.fontSize.sm,
lineHeight: 20,
},
});