Skip to content
Native Animation

React Native Accordion with Reanimated and Auto Height

By Native Animation · Updated

A React Native accordion collapses inline content by measuring its natural height and animating the visible height between zero and that measurement. Use a persistent header button, one interruptible open progress, and a parent-owned set of open IDs. This Expo example handles long answers, content changes, and repeated toggles without a fixed answer height.

What a collapsible accordion should do

Opening an answer should move the content below it in normal layout. The header remains available throughout the transition, and closing removes the answer from touch and accessibility navigation immediately. This is different from a modal: use the overlays guide when content should float above the screen.

The example targets Expo SDK 58, React Native 0.88.0, Reanimated 4.7.1, and Worklets 0.13.0. Install compatible packages with npx expo install react-native-reanimated react-native-worklets (bunx expo install in Bun projects). Follow the SDK 58 setup and save this complete file as AccordionExample.tsx outside the Expo Router route directory. Render it in a safe-area screen; the example owns its vertical scrolling.

Measure the expanded content

Measure an inner View at its natural height with onLayout. Its absolute positioning prevents the clipped parent height from feeding back into the measurement. The parent still receives a real animated height, so surrounding rows move with it.

import { useEffect, useState, type ReactNode } from 'react';
import { Platform, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import Animated, {
  useAnimatedStyle, useReducedMotion, useSharedValue, withSpring,
} from 'react-native-reanimated';

type SectionProps = {
  title: string;
  open: boolean;
  onToggle: () => void;
  children: ReactNode;
};

export function AccordionSection({ title, open, onToggle, children }: SectionProps) {
  const height = useSharedValue(0);
  const progress = useSharedValue(open ? 1 : 0);
  const reduced = useReducedMotion();
  useEffect(() => {
    progress.set(reduced ? Number(open) : withSpring(Number(open), {
      stiffness: 260, damping: 30, mass: 1, overshootClamping: true,
    }));
  }, [open, progress, reduced]);
  const clipStyle = useAnimatedStyle(() => ({
    height: height.get() * progress.get(),
    opacity: progress.get(),
  }));
  return (
    <View style={styles.section}>
      <Pressable accessibilityRole="button" accessibilityLabel={title}
        accessibilityState={{ expanded: open }} onPress={onToggle}
        style={styles.header}>
        <Text style={styles.title}>{open ? '− ' : '+ '}{title}</Text>
      </Pressable>
      <Animated.View style={[styles.clip, clipStyle]}
        pointerEvents={open ? 'auto' : 'none'}
        accessibilityElementsHidden={!open}
        importantForAccessibility={open ? 'auto' : 'no-hide-descendants'}
        {...(Platform.OS === 'web' ? { inert: !open } : {})}>
        <View style={styles.measure}
          onLayout={(event) => height.set(event.nativeEvent.layout.height)}>
          {children}
        </View>
      </Animated.View>
    </View>
  );
}

export function AccordionExample() {
  const [openIds, setOpenIds] = useState<string[]>([]);
  const [longAnswer, setLongAnswer] = useState(false);
  function toggle(id: string) {
    setOpenIds((ids) => ids.includes(id) ? ids.filter((item) => item !== id) : [...ids, id]);
  }
  return (
    <ScrollView contentContainerStyle={styles.screen}>
      <Pressable accessibilityRole="button" onPress={() => setLongAnswer((value) => !value)}
        style={styles.header}><Text>Change answer length</Text></Pressable>
      <AccordionSection title="When will my order arrive?" open={openIds.includes('delivery')}
        onToggle={() => toggle('delivery')}>
        <Text style={styles.answer}>{longAnswer
          ? 'Standard delivery takes 5–7 business days. Your confirmation includes tracking details. Delivery can take longer during holidays; contact support if tracking stops updating.'
          : 'Standard delivery takes 5–7 business days.'}</Text>
      </AccordionSection>
      <AccordionSection title="Can I change my address?" open={openIds.includes('address')}
        onToggle={() => toggle('address')}>
        <Text style={styles.answer}>Contact support before your order ships.</Text>
      </AccordionSection>
    </ScrollView>
  );
}
const styles = StyleSheet.create({
  screen: { padding: 24, gap: 12 },
  section: { borderWidth: 1, borderColor: '#767080', borderRadius: 12 },
  header: { minHeight: 48, padding: 16, justifyContent: 'center' },
  title: { color: '#231D2B', fontSize: 18 },
  clip: { overflow: 'hidden' },
  measure: { position: 'absolute', top: 0, left: 0, right: 0 },
  answer: { color: '#393240', padding: 16, fontSize: 16, lineHeight: 24 },
});

Content that changes after opening

Tap Change answer length while the first section is open. The inner layout fires again and updates the measured height. It also remeasures while closed, so the next opening uses current content. This example applies new measurements immediately; it springs opening progress, rather than promising an animated resize for every asynchronous content change.

Long text, images, and narrow containers

Text wraps at the measured container width; a width or font-scale change produces a new measurement. For images, reserve a size or aspect ratio before loading so the answer does not jump when decoding finishes. Do not constrain the measuring View to the outer clip height or put a second vertical ScrollView inside a short FAQ answer.

Use one interruptible open progress

The shared value is a presentation value. React owns the requested open state, and withSpring moves progress toward that state. Height and opacity derive from progress on the UI thread; no React update runs per frame. The spring specifies mass explicitly and clamps overshoot to keep the clip within its content.

Toggle again before the spring settles

Open, close halfway through, then open again. Each request retargets the current spring. There is no delayed completion callback that can overwrite a newer open state. This is the same measurement principle as the official Reanimated accordion example, with explicit interaction and hidden-content behavior added here.

Keep the header available while content moves

The header is outside the clip. Its minimum touch height stays available even when the answer is zero height. Do not animate the whole section to zero or put its only close control inside disappearing content.

Choose one open section or several

The example allows several IDs. For exclusive opening, replace the toggle update with setOpenIds((ids) => ids.includes(id) ? [] : [id]). Use stable IDs so sorting questions does not change which answer is open. The parent can reject or reset open requests without changing the rendering contract.

Accessibility and reduced motion

Expanded state and focus order

Each header announces its title and expanded state. Keep headings and reading order meaningful in the host screen. If another control closes a section containing the current focus, move focus to that section's header before closing; the example's header activation already leaves focus outside the answer.

Hidden content must not remain interactive

Clipping and opacity alone do not remove links from navigation. The example disables pointer events, hides native accessibility descendants, and forwards the web-only inert attribute through React Native Web 0.21 for the collapsed subtree, including web keyboard focus. It stays mounted to measure. Avoid grouping the entire section into one accessible element when its answer contains independent controls.

The useReducedMotion hook reads the startup preference. With it enabled, progress jumps to its final value. For preference changes during a running session, subscribe to AccessibilityInfo's reduceMotionChanged event and apply the new setting immediately.

Use the catalog component

Auto Height Accordion packages the measured answer, fade, and chevron for string FAQ content. Its items contain id, title, and string content; it does not accept arbitrary React children as answers. Copy it with the CLI, then use its controlled API:

import { useState } from 'react';
import { AutoHeightAccordion } from './auto-height-accordion';

export function DeliveryFaq() {
  const [openIds, setOpenIds] = useState<string[]>([]);
  return <AutoHeightAccordion items={[
    { id: 'delivery', title: 'When will it arrive?', content: 'Within 5–7 business days.' },
    { id: 'address', title: 'Can I change my address?', content: 'Contact support before shipping.' },
  ]} value={openIds} onChange={setOpenIds} allowMultiple />;
}

The delivered ref offers open, close, and toggle by ID. Each published piece is reviewed on physical iPhone and Android devices with Expo Go before release. Test your final answer lengths, large text, rapid toggling, and focus with the surrounding screen.

FAQ

Can I animate height to auto?

Measure the natural content height and animate a numeric clip height. A fixed maximum either truncates long content or leaves empty space.

What happens when an answer grows after loading?

Remeasure it. This example immediately updates the height measurement while retaining the current open progress. Reserve image space when possible.

Should an accordion allow several open answers?

Use several when readers compare answers; use one when space or sequential reading matters. Keep that policy in the parent's open-ID state.