Step 1 — One image, lots of padding
You are not exporting a set of device-sized backgrounds. Expo takes one source image and centres it on a solid colour, so what you want is:
- 1024×1024 PNG, transparent background. Square keeps the maths simple across portrait and landscape.
- Logo mark only, centred, with padding. Roughly two-thirds artwork and one-third empty margin. That margin is what stops the logo crowding the edges on a small device.
- No text you care about reading. A tagline that is legible in your design tool is unreadable at splash scale on a phone.
Save it as ./assets/splash-icon.png. If you also want a dark version, export the same mark in light ink as ./assets/splash-icon-dark.png.
Step 2 — The config plugin
If you have seen an older tutorial, it probably told you to use a top-level expo.splash key. That still resolves, but the plugin is the supported path and the only one with the newer options:
{
"expo": {
"userInterfaceStyle": "automatic",
"plugins": [
[
"expo-splash-screen",
{
"image": "./assets/splash-icon.png",
"imageWidth": 200,
"resizeMode": "contain",
"backgroundColor": "#ffffff",
"dark": {
"image": "./assets/splash-icon-dark.png",
"backgroundColor": "#1c1c1c"
}
}
]
]
}
}The three options that matter:
- imageWidth — the rendered width in points, not the file size. 200 reads as confident on a phone; past ~300 the logo starts to feel like a mistake. This is the knob people forget exists, then compensate by re-exporting the PNG at odd sizes.
- backgroundColor— should be the background of your app’s first screen, not white-by-default. Matching them is what turns the handoff from a flash into a fade.
- dark — without it, dark-mode users get a white rectangle followed by a dark app. It costs one extra export.
Changing this block changes native project files, so it takes effect on the next build — npx expo prebuild --clean locally, or a fresh EAS build. Reloading the JS bundle will not do it.
Step 3 — Kill the white flash
This is the part almost everyone skips. By default the splash hides the moment the first React view mounts — which is before your fonts load, before you know whether the user is signed in, and before any data arrives. The result is splash → blank screen → content. Hold the splash yourself:
// app/_layout.tsx
import { useEffect } from 'react';
import { Stack } from 'expo-router';
import { useFonts } from 'expo-font';
import * as SplashScreen from 'expo-splash-screen';
import { useSession } from '../lib/session';
// module scope — runs before the first render
SplashScreen.preventAutoHideAsync();
export default function RootLayout() {
const [fontsLoaded, fontError] = useFonts({
Inter: require('../assets/fonts/Inter.ttf'),
});
const { isLoading: sessionLoading } = useSession();
const ready = (fontsLoaded || fontError) && !sessionLoading;
useEffect(() => {
if (ready) SplashScreen.hideAsync();
}, [ready]);
if (!ready) return null; // splash is still up — render nothing
return <Stack screenOptions={{ headerShown: false }} />;
}Two things worth pointing at. fontError is in the ready condition on purpose: if a font fails to load and you only gate on fontsLoaded, the splash never hides and the app looks frozen at launch — a bug that is invisible in development and reported as “the app won’t open” in review. And return null is what you want here, not a loading spinner: the splash is already on screen, so anything you render is hidden behind it anyway.
Recent versions of expo-splash-screen also expose SplashScreen.setOptions for a fade-out instead of a hard cut. It is a nice touch, but the ordering above is what removes the flash — the fade only polishes it.
Why it looks wrong in Expo Go
Expo Go shows its own launch screen. Your plugin config is not broken — it simply is not part of that app. To see the real thing you need a development build or a production build, which is the same reason camera, notifications, and other native config need a rebuild. The Expo Go vs development build breakdown covers where that line falls for everything else.
A 60-second checklist
- Square 1024×1024 transparent PNG, logo centred with padding.
expo-splash-screenplugin inapp.json,imageWidtharound 200.backgroundColormatches your first screen — in both themes.preventAutoHideAsync()at module scope,hideAsync()after fonts and session resolve.- Font-error case included in the ready condition, so a bad font can never freeze the splash.
- Checked on a real development build, in light and dark, on the smallest phone you support.
Or skip the config
Launch configuration is the kind of work that has exactly one correct answer and no creative upside. ShipNative generates the plugin block, the hide-when-ready wiring, and a splash background drawn from the same theme as the app it built — then previews it on your phone so you can see the actual first two seconds instead of imagining them. Everything exports as a normal Expo project, so you can still hand-tune every value above.