Theme

Accordion

Vertically stacked sections that expand and collapse with a measured-height Reanimated animation.

Prerequisites

React Native
>= 0.86.0 · New Architecture only
Registry
  • theme — copied once, see Installation

Installation

npx pocketui add accordionComing soon
  1. 1. Copy into components/ui/accordion.tsx

    components/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.

demos/accordion.tsx
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,
  },
});

On this page