Munchable is an Expo app. The same code builds the Android app and the browser version at app.munchable.app, and the marketing site beside it is a separate Next.js app. The app is full of rows that open: a scan result with its reasons folded away, a recipe with its method hidden until you want it, a history entry, a condition picker that shows nine items and keeps the rest behind a chevron.
That is the most ordinary interaction in mobile UI, and it is where we did the most damage.
The API we are not allowed to use
React Native Reanimated ships layout animations. You write entering={FadeIn} on a view, or layout={LinearTransition}, and movement appears for free. We used them for a few weeks and then wrote a rule into our design tokens banning them from the codebase.
The reason is what they compile to. A layout animation pins the view with position: absolute while it plays, because that is the only way to animate a box independently of the boxes around it. The view is supposed to come back into flow when the animation finishes. Sometimes it does not, and it can stay pinned for as long as the screen remains mounted.
On our scan result screen that produced a specific and quite bad bug. The screen prints a short verdict word, a confidence badge under it, and a sentence under that. The verdict word had an entering animation. It finished the animation out of the flow, which meant the layout beneath it had closed up around a gap that was no longer there, and the word was painted on top of the badge and the first line of the sentence. A health app answering a question about a food, with the answer overlapping its own caveat.
It is also not a bug you catch quickly. It survives a reload, it varies with timing, and in a screenshot it just looks like bad CSS.
So the rule in tokens.ts is that we animate opacity, transforms, the width of a bar inside a fixed track, and the height of a clipping box that owns the space it is collapsing. Nothing else.
Fade: the replacement for entering
const fade = useAnimatedStyle(() =>
rise === 0
? { opacity: progress.value }
: {
opacity: progress.value,
transform: [{ translateY: (1 - progress.value) * rise }],
},
);
An ordinary animated style on opacity and a transform. Neither of those touches layout, so the space the children occupy is byte for byte identical on the first frame and the last. rise (8 points, the house distance) moves what is painted, never what is reserved.
One detail worth copying: the two branches are shapes, not states. rise is fixed for the life of a Fade instance, so the set of keys in the style object never changes underneath Reanimated. A style that sometimes has transform and sometimes does not is a different kind of trouble.
Collapsible: the one place we animate a layout property
A disclosure genuinely has to change the page's height. It has to push what is below it down. So this component is the single exception, and it is safe for a structural reason: the children are laid out normally, once, and the thing that moves is the parent box that owns their space. Nothing is ever hoisted out of the flow.
The interesting choice is maxHeight rather than height:
const UNCLAMPED = 100000;
const box = useAnimatedStyle(() => ({
maxHeight: progress.value >= 1 ? UNCLAMPED : progress.value * natural.value,
opacity: progress.value,
}));
natural.value comes from an onLayout on an inner wrapper. progress is one shared value driven with withTiming.
Two bugs are bought by that >= 1 branch.
A row that starts open is open on its first frame. Some screens arrive with rows already expanded. With height, an unmeasured open box is zero tall, so the row renders shut, a measurement lands, and it animates itself open on load. With maxHeight, an unmeasured open box is simply unclamped and takes its natural height immediately. There is no frame where it is wrong.
A settled open row cannot clip itself. The clamp is lifted the moment the row is fully open, which means anything that grows afterwards is accommodated: a user bumping their system text size, a reason wrapping onto a third line, a translation that is longer than the English. A height of "what I measured once" is a promise you cannot keep, and the failure mode is text sliced off mid-sentence. Between opening and closing there is no measurement in force at all, which is what makes a stale measurement harmless rather than dangerous.
One progress value drives everything, so a row toggled mid-animation turns around from where it actually is instead of snapping to an end state first.
Closed is not the same as hidden
Closed children stay mounted, because that is what keeps them measurable. That is a correctness problem on two surfaces that overflow: hidden does not solve:
<Animated.View
aria-hidden={!open}
style={[{ overflow: 'hidden', pointerEvents: open ? 'auto' : 'none' }, box, style]}
>
<View onLayout={onLayout}>{children}</View>
</Animated.View>
A screen reader must not read a shut row's contents, and a button inside a shut row must not be tappable even though it is technically zero pixels tall. pointerEvents goes in the style rather than the prop, which react-native has deprecated.
Then the web build produced one more thing to handle. A control inside the row is frequently the browser's focused element at the moment the row shuts, because it was the last thing tapped. Chrome refuses to apply aria-hidden over a focused element and logs a warning instead of doing what you asked. So the focus is dropped in the same task as the DOM update, before that check runs:
useLayoutEffect(() => {
if (open || typeof document === 'undefined') return;
const node = host.current as unknown;
const active = document.activeElement;
if (node instanceof HTMLElement && active instanceof HTMLElement && node.contains(active)) {
active.blur();
}
}, [open]);
useLayoutEffect, not useEffect, and that is the whole point of the fix.
Reduce Motion is a branch in every animated component
Both components call Reanimated's useReducedMotion and, when it is on, set the shared value directly instead of running withTiming. Same layout, same end state, no movement. There are three durations in the whole app (160ms for press feedback and chevrons, 220ms for content arriving, 600ms for the one progress bar where the sweep is the point) and one easing curve, an ease-out with no overshoot. A bounce would read as a toy, and the app answers questions about food and symptoms.
The chevron is worth a sentence on its own. One arrow that rotates 180 degrees, rather than two glyphs that swap. A rotation is a transform, so it cannot move anything: the arrow turns inside the box it already occupies regardless of what the row beneath it is doing. It is marked decorative, because the row it sits on already carries accessibilityState={{ expanded }}, which is the thing that actually gets announced.
What you can look at
The disclosure rows live in the app, but the content they open is public, so you can read the same material on the website and see what is being folded away:
- A recipe page such as Gentle rice congee is the web version of a screen whose method section is a
Collapsiblein the app. - The low FODMAP and IBS page is the long-form version of what the condition picker rows summarise.
- The answers index lists 373 generated pages, and the per-page detail is exactly the kind of body that used to be a second long.
If you take one thing from this: a layout animation that is 95% reliable is worse than no animation, because the 5% is a view parked outside the flow with your own text underneath it.
Top comments (0)