The first time I ran one of my own apps on a real device, a white frame flashed for a split second before the logo appeared — and I spent half a day figuring out how to fill that half-second cleanly. The splash screen is the very first glance a user exchanges with your app.
The screen that appears during the first 0.5 to 2 seconds of your app's launch is called the splash screen — known as the launch screen on iOS. It's the frame that fills the display while your JavaScript bundle loads and initializes.
Leaving it at the default (a plain white screen) is a missed opportunity. A polished splash screen with your brand's logo and colors instantly signals quality. It's also the perfect window to run async setup work — loading fonts, checking authentication status, prefetching data — before the user ever sees your main UI. The walkthrough below configures and customizes it in a Rork-generated Expo project, from basic app.json settings to smooth fade-out animations.
What Is expo-splash-screen?
expo-splash-screen is Expo's official library for controlling when the native splash screen disappears. Without it, the native splash screen hides the moment the JavaScript thread is ready — which can be earlier than you'd like if fonts haven't loaded yet.
The key benefits:
- Prevents white-screen flicker: Keeps the branded splash visible until you explicitly hide it, eliminating the jarring white flash between launch and first render.
- Awaits async work: You control exactly when the screen disappears — after fonts load, after an auth check, or after any other setup task completes.
- Works across iOS, Android, and web: One configuration file covers all platforms.
Configuring the Splash Screen in app.json
The starting point is app.json (or app.config.js) in your project root:
{
"expo": {
"name": "MyApp",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#FFFFFF"
},
"ios": {
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#FFFFFF",
"tabletImage": "./assets/splash-tablet.png"
}
},
"android": {
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#FFFFFF",
"mdpi": "./assets/splash-mdpi.png",
"hdpi": "./assets/splash-hdpi.png",
"xhdpi": "./assets/splash-xhdpi.png",
"xxhdpi": "./assets/splash-xxhdpi.png",
"xxxhdpi": "./assets/splash-xxxhdpi.png"
}
}
}
}Key fields to understand:
image: Your splash image in PNG format. Aim for at least 1242×2688px to look sharp on high-resolution iPhones.resizeMode:"contain"fits the entire image within the screen (ideal for logos with padding), while"cover"fills the screen edge to edge (good for full-bleed background artwork).backgroundColor: The color shown in the letterbox areas when using"contain", and as the fallback before the image loads. Match this to your logo's background color to avoid jarring color shifts.
Holding the Splash Until Async Work Completes
app.json alone handles the static image, but you'll often need to keep the splash visible while fonts load or an auth token is validated. Here's the standard pattern:
// app/_layout.tsx
import { useEffect, useState } from 'react';
import * as SplashScreen from 'expo-splash-screen';
import * as Font from 'expo-font';
import { View } from 'react-native';
// Prevent the native splash from auto-hiding before we're ready
SplashScreen.preventAutoHideAsync();
export default function RootLayout() {
const [appIsReady, setAppIsReady] = useState(false);
useEffect(() => {
async function prepare() {
try {
// Load fonts, check auth, prefetch data — anything your app needs at launch
await Font.loadAsync({
'CustomFont-Regular': require('../assets/fonts/CustomFont-Regular.ttf'),
});
// Example: check auth status
// await checkAuthStatus();
// Optional: artificial delay if you want to ensure a minimum display time
// await new Promise(resolve => setTimeout(resolve, 800));
} catch (e) {
// Log errors but don't block the app from launching
console.warn('Error during app preparation:', e);
} finally {
setAppIsReady(true);
}
}
prepare();
}, []);
const onLayoutRootView = async () => {
if (appIsReady) {
// Hide the splash once the root View has rendered
await SplashScreen.hideAsync();
}
};
if (!appIsReady) {
// Keep the native splash visible by rendering nothing
return null;
}
return (
<View style={{ flex: 1 }} onLayout={onLayoutRootView}>
{/* Your root navigation or app entry point goes here */}
</View>
);
}The flow in plain English:
preventAutoHideAsync()tells the native layer: "don't dismiss the splash yet."- Inside
useEffect, all async setup work runs inside atry/finallyblock. - When setup is done (whether it succeeded or failed),
setAppIsReady(true)fires. - The root
Viewrenders,onLayoutfires, andhideAsync()finally dismisses the splash.
Adding a Fade-Out Animation
The default hideAsync() is an instant cut. A subtle fade-out makes the transition feel polished. The trick is to render a custom overlay on top of your app and animate its opacity to zero before calling hideAsync():
import { useEffect, useRef, useState } from 'react';
import * as SplashScreen from 'expo-splash-screen';
import { Animated, Image, StyleSheet, View } from 'react-native';
SplashScreen.preventAutoHideAsync();
export default function AnimatedSplash({ children }: { children: React.ReactNode }) {
const [isAppReady, setIsAppReady] = useState(false);
const [isSplashGone, setIsSplashGone] = useState(false);
const opacity = useRef(new Animated.Value(1)).current;
useEffect(() => {
async function prepare() {
await new Promise(resolve => setTimeout(resolve, 500)); // Replace with real work
setIsAppReady(true);
}
prepare();
}, []);
useEffect(() => {
if (isAppReady) {
Animated.timing(opacity, {
toValue: 0,
duration: 500, // 500ms fade-out
useNativeDriver: true, // Runs on the native thread for smooth 60fps
}).start(async () => {
await SplashScreen.hideAsync();
setIsSplashGone(true);
});
}
}, [isAppReady]);
return (
<View style={{ flex: 1 }}>
{/* Render the app behind the overlay so it's ready the instant the splash fades */}
{isAppReady && children}
{/* Custom splash overlay */}
{!isSplashGone && (
<Animated.View
pointerEvents="none"
style={[StyleSheet.absoluteFill, { backgroundColor: '#FFFFFF', opacity }]}
>
<Image
source={require('../assets/splash.png')}
style={{ flex: 1, resizeMode: 'contain' }}
/>
</Animated.View>
)}
</View>
);
}Rendering the app content underneath the overlay before the animation starts means your main screen is fully painted and ready to show the instant the fade completes — no jarring pop-in after the transition.
Prompting Rork for Splash Screen Setup
You can describe this requirement directly to Rork in the AI chat:
I want to customize the splash screen in my Expo app.
- Use expo-splash-screen to prevent auto-hide
- Keep the splash visible until custom fonts have loaded
- Add a fade-out animation when the splash disappears
- Show me the app.json configuration too
Rork will generate the relevant files and configuration. From there, swap in your actual asset paths, brand colors, and any additional async tasks.
Two Subtle Gotchas I Only Caught on a Real Device
Even with everything configured by the book, the launch can still feel slightly off on a physical device. As an indie developer shipping my own apps, I got tripped up by two small things here.
The first is the background color. If backgroundColor doesn't exactly match the background of your first real screen, the moment the splash disappears the color snaps to a new value — and it reads like the screen flickered. I had a navy splash cutting to a white screen, noticed the flash myself, and only then traced it back to the mismatch. Keep the splash background and your first-rendered screen on the same color value.
The second is timing. When your setup work finishes almost instantly, the splash vanishes so fast it looks like a single flash of light. Hold it too long, though, and users assume your app is slow to boot. In my experience, enforcing a minimum display time of around 600–800ms hits the sweet spot — long enough to feel intentional, short enough to feel fast. Whether you add that floor with a setTimeout is worth tuning against how much data you actually load at launch.
Common Errors and Fixes
Splash image appears stretched
Check that resizeMode is set to "contain", not "cover". If the image still looks wrong, make sure you're providing a high-resolution PNG (1242×2688px or larger).
Splash doesn't appear on iOS
If you changed app.json after an EAS Build, the native binary needs to be rebuilt. Run eas build again so the new splash configuration is baked into the native layer.
preventAutoHideAsync isn't working — splash still disappears immediately
This usually means hideAsync() is being called somewhere unexpectedly. Search your project for all calls to SplashScreen.hideAsync() and make sure only one code path invokes it.
Summary
Customizing the splash screen in a Rork Expo app is a three-step process:
- Configure the image, resize mode, and background color in
app.json. - Use
SplashScreen.preventAutoHideAsync()andSplashScreen.hideAsync()to control exactly when the splash disappears, even while async work is running. - Optionally layer a custom
Animatedoverlay for a smooth fade-out transition.
A well-branded splash screen sets a professional tone before users even reach your first real screen. To continue refining the visual quality of your app, the Rork UX Design Patterns Complete Guide is a great next read — it covers screen layout, micro-interactions, and animation techniques in depth.
For a comprehensive reference on Expo's native capabilities — including splash screens, notifications, and device APIs — the official Expo documentation is always the authoritative source.