Your React Native confetti renders behind the cards: sibling order, zIndex, and elevation
A full-screen overlay that renders behind the content it is supposed to celebrate is almost never a z-index bug. It is a paint-order bug, and Android adds a second twist on top of it.
When you finish a puzzle in Daily Sudoku: Offline Puzzle, the win screen throws a short burst of confetti over the stars, the time, and the score card. It is a small effect, deliberately cheap to render: a few colored pieces fall from the top of the screen once and fade out. It does not need to look like a stadium celebration. It only needs to mark the second where the game says “nice job” before the next puzzle.
For a while that moment failed in two ways. The confetti fell behind the result cards, so half of it disappeared as soon as it entered the dense part of the screen. And what you could see fell too fast, closer to dropped paper than a celebration. Both bugs lived in the same small component, and both are worth writing down because the root cause shows up in a lot of React Native UI.
The symptom: a celebration you can barely see
The overlay is a StyleSheet.absoluteFill view with a set of animated pieces inside it. On the win screen the tree looked, in essence, like this:
<SafeAreaScreen className="flex-1 bg-background px-5">
<WinCelebration enabled={isAnimated} colors={colors} />
<View className="flex-1 justify-between py-4">
{/* stars card, time/score card, action buttons */}
</View>
</SafeAreaScreen>
That reads naturally from top to bottom: set up the celebration, then render the content. It was also exactly backwards for the behavior we wanted. The confetti was painted first, the cards came after, and the cards won. The pieces were there the whole time, animating and dropping down the screen. They were just underneath everything.
If your instinct is “add a zIndex”, hold on. That instinct is what sends people down a frustrating hour of adding zIndex: 999 to random views and watching nothing change.
Why it renders behind: sibling paint order, not z-index
In the browser, z-index is the mental model most developers reach for first. In React Native there is a simpler rule underneath it that you have to internalize: among siblings, later children paint on top of earlier children. There is no implicit stacking context tied to positioning the way there is on the web. If two siblings overlap and neither sets zIndex, the one that appears later in the JSX wins.
So the fix is not a magic number. It is document order. Move the overlay so it is the last child of the screen:
<SafeAreaScreen className="flex-1 bg-background px-5">
<View className="flex-1 justify-between py-4">
{/* stars card, time/score card, action buttons */}
</View>
<WinCelebration enabled={isAnimated} colors={colors} />
</SafeAreaScreen>
That single move fixes iOS and web immediately. The overlay is absolutely positioned, so it does not affect the layout of the content, and because it is now the last sibling it paints on top. This is the version of the fix that most tutorials stop at, and if you only ever shipped to iOS you would think you were done.
You are not done, because Android has its own opinion.
The Android twist: elevation beats zIndex
On Android, React Native maps onto the native view system, and that system has a property called elevation. Elevation controls the Material drop shadow, but it also participates in stacking: a view with higher elevation can render above a sibling even when JSX order or zIndex would suggest otherwise. Cards, surfaces, and anything styled to look “raised” often carry elevation, sometimes indirectly through a shared surface style.
This is the part that burns people. You reorder your JSX, it looks perfect on the iOS simulator, you ship, and an Android tester reports the confetti still vanishes behind the cards. The reordering was correct; it just is not sufficient when a sibling has elevation and your overlay has none.
The robust fix is to be explicit on both axes. Set zIndex for the cross-platform stacking hint and elevation for Android specifically:
<View pointerEvents="none" style={[StyleSheet.absoluteFill, { zIndex: 20, elevation: 20 }]}>
{pieces.map(({ id, ...piece }) => (
<ConfettiPiece key={id} {...piece} />
))}
</View>
Two details matter here beyond the numbers. First, pointerEvents="none" is not optional. A full-screen overlay sitting on top of your buttons will happily swallow every tap if you let it, and the user will think the win screen is frozen. With pointerEvents="none", touches pass straight through to the “New game” and “Home” buttons underneath. Second, the values do not need to be large. zIndex: 20 is not more powerful than zIndex: 5; it just needs to beat whatever the cards use. Round numbers with a little headroom keep the intent readable.
Combining reordering and the explicit zIndex/elevation pair is intentionally redundant. The reordering expresses the intent cleanly; the explicit stacking values defend against a sibling that quietly carries elevation now or later.
The second bug: falling too fast
With the overlay finally visible, the other problem became obvious: the pieces dropped in about a second and were gone. Confetti that fast does not read as celebration; it reads as an accident.
Each piece animates with Reanimated, driving a single shared value from 0 to 1 and mapping that onto a downward translation with a little horizontal drift and rotation. The speed lives entirely in the animation duration:
// before: ~1.1s to ~1.9s, depending on the piece
durationMs: 1100 + (index % 4) * 260,
// after: ~2.3s to ~3.6s
durationMs: 2300 + (index % 4) * 420,
Roughly doubling the duration is the whole change. It is tempting to “fix” a thin-looking effect by adding more pieces, but that is the wrong lever for two reasons. More pieces cost more per frame, which matters on low-end hardware. And density is not what was missing; time on screen was. Slower pieces linger, overlap more naturally as they stagger, and give the eye something to follow. The modulo-based spread (index % 4) keeps the pieces from falling in lockstep, so they still feel organic rather than like a single sheet dropping.
Two controls shape perceived speed here: the per-piece durationMs and the small per-piece delayMs that staggers when each one starts. We nudged both. The staggering is what turns sixteen identical falls into something that looks like a scatter.
Keeping the effect honest on cheap phones
None of this is worth much if it stutters on the devices most of our players actually use. The whole celebration is designed to be inexpensive: sixteen pieces, one animation each, running on the UI thread through Reanimated so the JavaScript thread stays free for navigation and the ad request that follows the win. The overlay never intercepts touches, so it adds no gesture work. And the effect is gated behind the app’s animation setting, so players who choose reduced or disabled motion do not pay for it at all.
That last point is easy to forget. A celebration overlay is exactly the kind of decorative motion that an accessibility-minded “reduce animations” setting should turn off. Wiring the effect to that setting from the start is cheaper than retrofitting it, and it means the performance budget on low-end phones has an escape hatch that is also the right accessibility behavior.
How to spot this class of bug
The overlay-behind-content bug is common enough that it is worth a small checklist. If a decorative or celebratory layer is not showing up where you expect on top of content:
- Check sibling order first, not z-index. Ask which element appears later in the JSX. In React Native, later siblings paint on top by default, and reordering is usually the cleanest fix.
- Confirm on Android specifically. If it looks right on iOS but wrong on Android, suspect
elevation. A raised sibling can beat your overlay even with correct ordering. Set an explicitelevationon the overlay to match or exceed it. - Verify touches still pass through. If buttons under a full-screen overlay stop responding, you are missing
pointerEvents="none". This is easy to miss because the overlay is invisible between animations. - Separate position from stacking. Absolute positioning keeps the overlay out of layout flow, but it does not by itself decide what paints on top.
- Tune motion with duration and delay, not count. If an effect feels thin, more time on screen usually reads better than more elements, and it is kinder to the frame budget.
The takeaway
The fix here was three small edits: move the overlay to the end of the tree, add an explicit zIndex/elevation pair with pointerEvents="none", and roughly double the fall duration. The part worth keeping is not the diff. It is the mental model.
React Native stacking is paint order among siblings first, zIndex second, and on Android, elevation as a native tiebreaker that can override both if you are not explicit. Once that model is in your head, “my overlay is behind the content” stops being a mystery you solve by cranking a number and becomes a one-line reorder plus a deliberate Android guard. And once the confetti is actually on top, the only thing left to get right is letting it linger long enough to feel like a win.