All posts
React Native
3 min read

React Native shadows that actually work on both platforms

iOS and Android disagree about shadows. Here's a small, predictable approach that looks the same on both — plus why elevation keeps betraying you.

Shadows are the classic React Native papercut. You style a card, it looks great on the simulator, and then it's completely flat on Android. The reason is that the two platforms model shadows differently, and the RN API leaks both models at once.

The two models

On iOS, shadows come from four shadow* props:

const card = {
  shadowColor: "#0f172a",
  shadowOffset: { width: 0, height: 8 },
  shadowOpacity: 0.12,
  shadowRadius: 16,
};

On Android, none of those do anything. Android only understands a single number, elevation, which drives a Material-style shadow you don't get to shape:

const card = { elevation: 6 };

So the naive fix — set all five props — gives you two different shadows, and you tune one blind on each platform.

A predictable approach

Pick your iOS shadow first, because it's the one you can actually control. Then map it to an elevation that reads as roughly the same depth. I keep a tiny scale:

import { Platform } from "react-native";

type Depth = "sm" | "md" | "lg";

const ios: Record<Depth, object> = {
  sm: { shadowOpacity: 0.08, shadowRadius: 6, shadowOffset: { width: 0, height: 2 } },
  md: { shadowOpacity: 0.12, shadowRadius: 16, shadowOffset: { width: 0, height: 8 } },
  lg: { shadowOpacity: 0.16, shadowRadius: 28, shadowOffset: { width: 0, height: 14 } },
};

const android: Record<Depth, number> = { sm: 2, md: 6, lg: 12 };

export function shadow(depth: Depth) {
  return Platform.select({
    ios: { shadowColor: "#0f172a", ...ios[depth] },
    android: { elevation: android[depth] },
    default: {},
  });
}

Now style={shadow("md")} is honest about what it does, and the mapping lives in one place instead of scattered across components.

Three things that bite you

  1. overflow: "hidden" kills iOS shadows. The shadow is drawn outside the box, so clipping the box clips the shadow. Put the radius/clip on an inner view and the shadow on the outer one.
  2. Android elevation needs a background color. A transparent view casts no shadow. Always pair elevation with a solid backgroundColor.
  3. Elevation also changes z-order. A higher elevation can render on top of siblings regardless of layout order. If something overlaps unexpectedly, check elevation before zIndex.

If you want to eyeball the iOS values, I keep a little React Native shadow generator in my toolkit — tweak sliders, copy the style object.

The takeaway

Don't try to make one set of props look right on both platforms. Design the iOS shadow, map it to an elevation step, and centralize the mapping. Boring, but it stops the "why is it flat on Android" loop for good.