Flutter Animated Splash Screen: The Complete, Fast, and Accessible Guide
Build a fast, polished animated splash in Flutter: native launch, in-app animation, Rive/Lottie options, async init orchestration, and best practices.
Image used for representation purposes only.
Why an animated splash screen matters
A great splash experience does two things: it hides unavoidable cold‑start time and sets the tone for your brand without making users wait. In Flutter, the most reliable pattern is a two‑stage splash:
- Stage 1: Native, static launch screen (shown by iOS/Android before Flutter renders).
- Stage 2: In‑app, animated splash (a lightweight Flutter screen that appears immediately after the first frame and disappears as soon as initialization finishes).
This guide walks you through the architecture, code, and best practices to build a fast, polished animated splash screen that works on Android and iOS.
Architecture overview
Think of the startup flow as a pipeline:
- OS launch → show native static launch screen instantly (icon/branding, no animation).
- Flutter engine renders first frame → display AnimatedSplash widget.
- Run minimal required initialization in parallel.
- Fade/scale transition to your real home screen as soon as initialization completes (with a maximum timeout so you never trap the user).
Key principles:
- Keep the native splash simple; animate only once Flutter is ready.
- Never block the user for marketing; only for essential boot tasks.
- Always provide a fast path (skip/reduce motion, deep links, hot restarts).
Step 1: Configure the native splash (static)
Use the flutter_native_splash package to generate correct platform launch screens, including dark mode and Android 12+.
Add to pubspec.yaml:
dev_dependencies:
flutter_native_splash: ^2.4.0
flutter_native_splash:
color: "#0A0E21" # background color (light)
color_dark: "#000000" # background color (dark)
image: assets/branding/icon.png
image_dark: assets/branding/icon_dark.png
android_12:
icon_background_color: "#0A0E21"
image: assets/branding/icon_android12.png
image_dark: assets/branding/icon_android12_dark.png
android: true
ios: true
Then run:
flutter pub run flutter_native_splash:create
Notes:
- Android 12+ shows a system splash using your app icon; the package config maps your assets to that system.
- iOS launch screens must remain static; keep layout simple and centered. Your animation will happen in-app.
Step 2: Create the animated in‑app splash
We’ll orchestrate initialization alongside a short animation, then transition to the home screen. The pattern below is flexible: swap in custom animations, Rive, or Lottie later without changing the orchestration.
main.dart (bootstrap and app shell)
import 'package:flutter/material.dart';
Future<void> bootstrap() async {
// Do only what’s essential before showing the real UI
// Examples: load persisted auth token, read local config, warm caches
await Future.delayed(const Duration(milliseconds: 400));
}
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final boot = bootstrap();
runApp(MyApp(boot: boot));
}
class MyApp extends StatelessWidget {
final Future<void> boot;
const MyApp({super.key, required this.boot});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(useMaterial3: true, brightness: Brightness.light),
darkTheme: ThemeData(useMaterial3: true, brightness: Brightness.dark),
home: SplashGate(boot: boot),
);
}
}
SplashGate (route to splash or home)
class SplashGate extends StatefulWidget {
final Future<void> boot;
const SplashGate({super.key, required this.boot});
@override
State<SplashGate> createState() => _SplashGateState();
}
class _SplashGateState extends State<SplashGate> {
late Future<void> _gateFuture;
@override
void initState() {
super.initState();
// Ensure the splash shows at least 600ms but never more than 3s
_gateFuture = Future.wait([
widget.boot,
Future.delayed(const Duration(milliseconds: 600)),
]).timeout(const Duration(seconds: 3), onTimeout: () {});
}
@override
Widget build(BuildContext context) {
final prefersReducedMotion = MediaQuery.of(context).accessibleNavigation;
return FutureBuilder<void>(
future: _gateFuture,
builder: (context, snapshot) {
final done = snapshot.connectionState == ConnectionState.done;
if (!done) {
return AnimatedSplash(reducedMotion: prefersReducedMotion);
}
return const HomeScreen();
},
);
}
}
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Home')),
body: const Center(child: Text('Hello, world!')),
);
}
}
AnimatedSplash (fade + scale, brand-safe)
class AnimatedSplash extends StatefulWidget {
final bool reducedMotion;
const AnimatedSplash({super.key, required this.reducedMotion});
@override
State<AnimatedSplash> createState() => _AnimatedSplashState();
}
class _AnimatedSplashState extends State<AnimatedSplash>
with SingleTickerProviderStateMixin {
late AnimationController _c;
late Animation<double> _scale;
late Animation<double> _opacity;
@override
void initState() {
super.initState();
final duration = widget.reducedMotion
? const Duration(milliseconds: 200)
: const Duration(milliseconds: 900);
_c = AnimationController(vsync: this, duration: duration)..forward();
_scale = Tween(begin: 0.96, end: 1.0).animate(
CurvedAnimation(parent: _c, curve: Curves.easeOutCubic),
);
_opacity = Tween(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: _c, curve: Curves.easeOut),
);
}
@override
void dispose() { _c.dispose(); super.dispose(); }
@override
Widget build(BuildContext context) {
final color = Theme.of(context).colorScheme.surface;
return ColoredBox(
color: color,
child: Center(
child: FadeTransition(
opacity: _opacity,
child: ScaleTransition(
scale: _scale,
child: Image.asset(
Theme.of(context).brightness == Brightness.dark
? 'assets/branding/wordmark_dark.png'
: 'assets/branding/wordmark.png',
width: 160,
filterQuality: FilterQuality.medium,
),
),
),
),
);
}
}
This animation is simple, GPU‑friendly, and brand‑safe. You can swap the Image.asset with a vector animation next.
Step 3: Rive or Lottie integration (optional)
Vector animations keep APK/IPA sizes small and render smoothly.
Add a Lottie example:
dependencies:
lottie: ^2.7.0
import 'package:lottie/lottie.dart';
class LottieSplash extends StatelessWidget {
final bool reducedMotion;
const LottieSplash({super.key, required this.reducedMotion});
@override
Widget build(BuildContext context) {
if (reducedMotion) {
return const AnimatedSplash(reducedMotion: true);
}
return ColoredBox(
color: Theme.of(context).colorScheme.surface,
child: Center(
child: Lottie.asset(
'assets/anim/splash.json',
width: 220,
frameRate: FrameRate.max,
delegates: LottieDelegates(
values: [
ValueDelegate.color(['BrandColor'], value: Theme.of(context).colorScheme.primary),
],
),
onLoaded: (c) {
// Optional: cap duration with a timer if boot finishes early
},
),
),
);
}
}
Rive works similarly with an Artboard and a state machine controller; listen for an animation end event and fall back to the same timeout logic used in SplashGate.
Step 4: Keep initialization lean
Decide what must block the UI and what can wait.
Blockers (okay to keep in bootstrap):
- Read local auth/session token.
- Load local configuration and critical feature flags.
- Migrate local database schema.
Defer (do after HomeScreen appears):
- Non‑critical remote fetches and warmups.
- Analytics/attribution setup.
- Prefetching images or large caches.
Pattern to keep things snappy:
Future<void> bootstrap() async {
await Future.wait([
_loadLocalAuthToken(),
_openDatabaseAndMigrate(),
]).timeout(const Duration(seconds: 3), onTimeout: () {});
}
Tip: Heavy CPU work should run in an isolate via compute() to avoid jank.
UX, branding, and accessibility
- Respect reduced motion: we used MediaQuery.accessibleNavigation to shorten or skip animations.
- Support dark mode assets and backgrounds.
- Avoid text on the splash unless it’s a logo; translations and long strings flicker and complicate layout.
- Target 600–1000ms visual dwell time; longer feels like a delay.
- If a deep link or push route is present, short‑circuit the animation and route immediately once minimal boot is complete.
Navigation and deep links
You can route directly after SplashGate resolves, using your app’s router. Example with Navigator 2.0 or go_router: once boot completes, compute the initial route from any pending intent/URI and pushReplacement to it. Always ensure the splash cannot be returned to via back navigation.
Performance checklist
- Bundle sizes: prefer vectors (Rive/Lottie) or tiny raster logos. Compress JSON and images.
- Keep animation overdraw minimal: use a flat background and a centered asset.
- Precache large images before building AnimatedSplash:
@override
void didChangeDependencies() {
super.didChangeDependencies();
precacheImage(const AssetImage('assets/branding/wordmark.png'), context);
}
- Avoid network calls during the splash unless they’re absolutely required to render the first screen.
- Test on low‑end/emulated devices; watch for shader compilation stutter on Android. Consider pre‑warming critical shaders by touching simple Material widgets on a hidden frame if needed.
Testing the splash
Write a widget test that waits for the minimum dwell time and asserts the HomeScreen appears.
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('transitions from splash to home', (tester) async {
await tester.pumpWidget(MyApp(boot: Future.value()));
// Initial frame shows splash
expect(find.byType(AnimatedSplash), findsOneWidget);
// Wait min dwell + a frame
await tester.pump(const Duration(milliseconds: 700));
await tester.pumpAndSettle();
expect(find.byType(HomeScreen), findsOneWidget);
});
}
For integration tests, launch the full app and assert that navigation occurs within a timeout on both Android and iOS runners.
Troubleshooting
- White flash before splash: ensure your native splash background color matches your app surface color.
- Double splash on Android 12+: do not add another Activity‑level splash theme over the system one; rely on flutter_native_splash config.
- Layout jump on theme change: keep the AnimatedSplash background synced with your theme’s surface color and avoid insets that might react to SafeArea changes.
- Long delays: profile with Flutter DevTools’ startup timeline; cap Future.wait with a timeout as shown.
Security and privacy
Do not fetch secrets or perform sensitive network calls during splash. If you must read encrypted local data, keep it local and fast; defer remote handshakes to after the first screen when possible.
Production checklist
- Native static splash configured for light/dark and Android 12+.
- AnimatedSplash respects reduced motion and dark mode.
- Bootstrap capped with min/max dwell times.
- Deep links route without returning to splash.
- Tests ensure transition reliability on CI.
- Measured on slow devices; no jank during animation.
Wrap‑up
A modern Flutter splash is fast, brand‑consistent, and respectful of the user’s time. Use a static native launch for instant feedback, then a short, accessible in‑app animation while essential setup completes. Keep your initialization lean, cap the duration, and test across devices. The result is a polished first impression that scales with your app as it grows.
Related Posts
Flutter Permission Handling Best Practices: A Practical, Privacy‑First Guide
Build privacy-first Flutter apps with robust permission handling across Android and iOS. Learn UX patterns, permission_handler code, and testing tips.
Flutter + TensorFlow Lite: Local AI Integration Guide
A practical guide to integrating TensorFlow Lite models into Flutter for fast, private, offline on-device AI with performance tuning and code examples.
Flutter + Stripe Payment Integration: PaymentSheet, Webhooks, and a Production Checklist
Step-by-step Flutter + Stripe integration using PaymentSheet, with backend setup, webhooks, wallets, testing, and a production checklist.