Height is measured, not guessed. There's no way to animate to "auto" height in React Native, so each Accordion renders its content twice: once visibly inside the animated, overflow-hidden container, and once absolutely positioned off-screen at opacity: 0 purely to read its natural height via onLayout. That measured number becomes the animation's target.
The first measurement snaps, it doesn't animate. If an Accordion mounts already expanded, growing from 0 on first paint would read as a loading glitch rather than an intentional reveal, so a ref tracks whether the first measurement has landed. That one snaps the height directly; every toggle after it animates with withTiming.
Measuring and toggling are two separate effects.onLayout on the hidden measurement pass can re-fire after mount (fonts settling in, icons loading), and if that shared a dependency array with the toggle animation it would restart the height animation mid-flight, reading as a stutter rather than a resize. The measurement effect only snaps or silently corrects height; the toggle effect, keyed only on expanded, is the only thing that ever calls withTiming.
Content fades alongside the height. A clipped overflow-hidden container growing or shrinking around real text otherwise shows it getting visibly cut off mid-line for a frame or two. Fading the inner content's opacity in sync with the height animation covers that up, so the reveal reads as a clean dissolve instead of a clipped stutter.
withTiming, not withSpring, on purpose. An accordion header gets tapped open and shut repeatedly, browsing an FAQ, say, and a spring's overshoot on every toggle gets old fast. Opening eases out, closing eases in, and closing is quicker than opening, so the panel feels like it's tucking itself away rather than lingering.
AccordionGroup doesn't own any open/closed state itself. It's a thin map over items that derives each Accordion's expanded from item.id === openId, and flips openId to null when you tap the already-open item's header. All the actual collapse logic lives in Accordion; the group only enforces "only one at a time."
selectionAsync(), not impactAsync(). Opening a panel is picking one of several sections to look at, closer to choosing an option than flipping a binary toggle, so it uses the same haptic as a segmented control or radio pick.