Streaming83 views

How to Build a Streaming App Splash Screen in Flutter (Full Code + Preview)

A streaming app opens on a dark, cinematic splash while the catalog loads, and that first second sets the mood. In this tutorial you'll build the launch screen for 'Cineo', a Netflix-style movie service: a near-black canvas, a hand-painted play-button brandmark that glows red, a wide-tracked CINEO wordmark, a tagline, and an animated red shimmer bar that sweeps left to right while content loads. Every mark is drawn with CustomPainter, the whole screen forces its own dark theme, and it's pure Flutter with one bundled font.

Cineo · Splash — Streaming Flutter UI screen
Live preview — Cineo · Splash, built in pure Flutter.

Watch the Flutter UI walkthrough

A short screen recording of Cineo · Splash running, if you'd rather see it before you read the code. The written tutorial below covers everything in it.

Can't see the video? Watch it on YouTube.

What you'll build

  • A self-contained dark splash that forces ThemeData.dark so it looks right pushed as any route
  • A custom-painted brandmark: a red gradient tile with a soft glow and a play triangle drawn by hand (no image asset)
  • A CINEO wordmark with heavy weight and 8px letter-spacing, plus a muted tagline
  • An infinitely looping red shimmer loading bar built from an AnimationController and a CustomPainter
  • Correct animation lifecycle — a ticker mixin, a repeating controller, and disposal on teardown

Step-by-step build

1

Create the file

Add a new file at lib/stream_onboarding_splash/stream_onboarding_splash_screen.dart in your Flutter project.

2

Register the bundled fonts

No external packages — this is pure Flutter. It does bundle its design font (Inter), so drop the font file into fonts/ and declare it in pubspec.yaml:

pubspec.yaml
flutter:
  fonts:
    - family: Inter
      fonts:
        - asset: fonts/Inter-Regular.ttf
3

Build it, piece by piece

Here's how the screen goes together. Each block below is a real slice of the code with a plain-English explanation — paste them in order, or grab the whole file from the next section.

Imports, the stateful widget, and the Cineo color tokens

stream_onboarding_splash_screen.dart
import 'package:flutter/material.dart';

/// Splash — branded launch screen for **Cineo**, a premium cinematic streaming
/// app (Netflix / Disney+ / HBO Max mold). Self-contained per CONVENTIONS.md:
/// pure Flutter, the exact design font (Inter) is bundled under `fonts/`, and
/// the screen forces its own dark theme so it renders standalone when pushed as
/// a route. The wordmark and play mark are fully painted (no raster logo).
class StreamOnboardingSplashScreen extends StatefulWidget {
  const StreamOnboardingSplashScreen({super.key, this.onContinue});

  /// Optional tap / auto-advance callback used by the gallery to chain the flow.
  final VoidCallback? onContinue;

  static const String _font = 'Inter';

  // ── Cineo cinematic token palette (inline, shared across streaming screens) ──
  static const Color _bg = Color(0xFF0B0B0F);
  static const Color _brand = Color(0xFFE50914);
  static const Color _brandDark = Color(0xFFB00610);
  static const Color _text = Color(0xFFFFFFFF);
  static const Color _muted = Color(0xFF6B6B76);

  @override
  State<StreamOnboardingSplashScreen> createState() =>
      _StreamOnboardingSplashScreenState();
}

The file imports only Flutter's material library, then declares StreamOnboardingSplashScreen as a StatefulWidget because it will drive a looping animation. It exposes one optional callback, onContinue, that the gallery uses to advance the flow when the screen is tapped. Five private const Colors act as design tokens: _bg (#0B0B0F) is the near-black canvas, _brand (#E50914) is the signature streaming red, _brandDark (#B00610) is its deeper shade for the gradient, _text is white for the wordmark, and _muted (#6B6B76) is the grey for the tagline. _font pins everything to the bundled Inter typeface.

The looping animation controller and its lifecycle

stream_onboarding_splash_screen.dart
class _StreamOnboardingSplashScreenState
    extends State<StreamOnboardingSplashScreen>
    with SingleTickerProviderStateMixin {
  late final AnimationController _controller;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 1600),
    )..repeat();
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

The State class mixes in SingleTickerProviderStateMixin, which supplies the single 'vsync' ticker an AnimationController needs to sync with the screen's refresh rate. In initState the controller is created with a 1600ms duration and immediately '..repeat()', so its value cycles from 0 to 1 forever — that endless cycle is what powers the shimmer later. The matching dispose() calls _controller.dispose() before super.dispose(), releasing the ticker so the animation doesn't leak or keep running after the screen is gone.

Forcing dark mode, a tap-through scaffold, and the centered brand column

stream_onboarding_splash_screen.dart
  @override
  Widget build(BuildContext context) {
    return Theme(
      data: ThemeData.dark(useMaterial3: true),
      child: Scaffold(
        backgroundColor: StreamOnboardingSplashScreen._bg,
        body: GestureDetector(
          onTap: widget.onContinue,
          behavior: HitTestBehavior.opaque,
          child: SafeArea(
            child: SizedBox.expand(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.center,
                children: <Widget>[
                  const Spacer(),
                  const _CineoMark(size: 92),
                  const SizedBox(height: 26),
                  const Text(
                    'CINEO',
                    style: TextStyle(
                      fontFamily: StreamOnboardingSplashScreen._font,
                      fontSize: 34,
                      fontWeight: FontWeight.w800,
                      letterSpacing: 8,
                      color: StreamOnboardingSplashScreen._text,
                    ),
                  ),
                  const SizedBox(height: 10),
                  const Text(
                    'Movies & TV, unlimited',
                    style: TextStyle(
                      fontFamily: StreamOnboardingSplashScreen._font,
                      fontSize: 13.5,
                      fontWeight: FontWeight.w500,
                      letterSpacing: 0.4,
                      color: StreamOnboardingSplashScreen._muted,
                    ),
                  ),
                  const Spacer(),

build() wraps everything in a Theme with ThemeData.dark(useMaterial3: true) so the splash renders correctly even when pushed onto a light app. The Scaffold is painted with _bg, and a GestureDetector with behavior HitTestBehavior.opaque makes the whole surface tappable — firing onContinue anywhere, not just on the logo. Inside SafeArea, a SizedBox.expand forces a full-screen Column. A Spacer at the top and one after balance the group vertically: the 92px _CineoMark, then the 'CINEO' wordmark at 34px, weight w800, with a bold 8px letterSpacing, then the 13.5px muted 'Movies & TV, unlimited' tagline.

The animated red shimmer loading bar

stream_onboarding_splash_screen.dart
                  const Spacer(),
                  // Brand-red shimmer loading bar.
                  SizedBox(
                    width: 132,
                    height: 3,
                    child: AnimatedBuilder(
                      animation: _controller,
                      builder: (BuildContext context, Widget? child) {
                        return CustomPaint(
                          painter: _ShimmerBarPainter(progress: _controller.value),
                        );
                      },
                    ),
                  ),
                  const SizedBox(height: 44),
                ],
              ),
            ),
          ),
        ),
      ),
    );
  }
}

Below the second Spacer sits the loading indicator: a 132×3 SizedBox holding an AnimatedBuilder wired to _controller. AnimatedBuilder rebuilds only its child on every frame the controller ticks, so it hands the current _controller.value (0 to 1) into a CustomPaint using _ShimmerBarPainter. That's the efficient way to repaint a thin bar 60 times a second without rebuilding the whole tree. A final SizedBox(height: 44) lifts the bar off the very bottom edge.

Painting the brandmark — a gradient tile with a play triangle

stream_onboarding_splash_screen.dart
/// The Cineo brandmark — a brand-gradient rounded square with a painted play
/// triangle punched out of a soft cinematic glow.
class _CineoMark extends StatelessWidget {
  const _CineoMark({required this.size});

  final double size;

  @override
  Widget build(BuildContext context) {
    return Container(
      width: size,
      height: size,
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(size * 0.28),
        gradient: const LinearGradient(
          begin: Alignment.topLeft,
          end: Alignment.bottomRight,
          colors: <Color>[
            StreamOnboardingSplashScreen._brand,
            StreamOnboardingSplashScreen._brandDark,
          ],
        ),
        boxShadow: <BoxShadow>[
          BoxShadow(
            color: StreamOnboardingSplashScreen._brand.withValues(alpha: 0.35),
            blurRadius: 32,
            spreadRadius: 2,
          ),
        ],
      ),
      child: CustomPaint(painter: _PlayMarkPainter()),
    );
  }
}

/// Paints a rounded white play triangle centered in the brandmark.
class _PlayMarkPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    final double w = size.width;
    final double h = size.height;
    final Paint paint = Paint()
      ..color = Colors.white
      ..style = PaintingStyle.fill
      ..strokeJoin = StrokeJoin.round
      ..strokeCap = StrokeCap.round
      ..strokeWidth = w * 0.12;
    final double cx = w * 0.54;
    final double cy = h * 0.5;
    final double r = w * 0.2;
    final Path tri = Path()
      ..moveTo(cx - r * 0.86, cy - r)
      ..lineTo(cx + r, cy)
      ..lineTo(cx - r * 0.86, cy + r)
      ..close();
    canvas.drawPath(
      tri,
      paint
        ..style = PaintingStyle.stroke,
    );
    canvas.drawPath(tri, paint..style = PaintingStyle.fill);
  }

  @override
  bool shouldRepaint(covariant _PlayMarkPainter oldDelegate) => false;
}

_CineoMark is a Container whose BoxDecoration rounds its corners to size * 0.28 and fills it with a topLeft-to-bottomRight LinearGradient from _brand to _brandDark. A BoxShadow using _brand.withValues(alpha: 0.35) with a 32px blur creates the soft red cinematic glow around the tile. Its child is a CustomPaint running _PlayMarkPainter, which builds a three-point Path — a triangle offset to cx = 54% width so it looks optically centered — and draws it twice, once stroked with rounded joins and once filled white, giving the play icon slightly softened corners.

The shimmer painter — a gradient window sweeping a dim track

stream_onboarding_splash_screen.dart
/// A brand-red gradient sweep that travels along a dim track — the splash
/// loading shimmer.
class _ShimmerBarPainter extends CustomPainter {
  const _ShimmerBarPainter({required this.progress});

  final double progress;

  @override
  void paint(Canvas canvas, Size size) {
    final RRect track = RRect.fromRectAndRadius(
      Offset.zero & size,
      Radius.circular(size.height / 2),
    );
    canvas.drawRRect(
      track,
      Paint()..color = const Color(0xFF2A2A33),
    );
    // Moving highlight window.
    final double windowW = size.width * 0.42;
    final double travel = (size.width + windowW) * progress - windowW;
    final Rect glow = Rect.fromLTWH(travel, 0, windowW, size.height);
    canvas.save();
    canvas.clipRRect(track);
    canvas.drawRect(
      glow,
      Paint()
        ..shader = const LinearGradient(
          colors: <Color>[
            Color(0x00E50914),
            StreamOnboardingSplashScreen._brand,
            Color(0x00E50914),
          ],
        ).createShader(glow),
    );
    canvas.restore();
  }

  @override
  bool shouldRepaint(covariant _ShimmerBarPainter oldDelegate) =>
      oldDelegate.progress != progress;
}

_ShimmerBarPainter takes the animation's progress value and draws two layers. First a fully-rounded RRect track in dark grey (#2A2A33) as the resting bar. Then a moving highlight: windowW is 42% of the width, and 'travel' maps progress so the window slides from just off the left edge to off the right. After clipRRect keeps the glow inside the pill, it fills that window with a horizontal LinearGradient that fades transparent → _brand → transparent, so a soft red streak appears to travel across. shouldRepaint returns true only when progress changes, avoiding wasted repaints.

Full code

The complete, ready-to-paste source. Free to use in your projects — one click copies it all.

import 'package:flutter/material.dart';

/// Splash — branded launch screen for **Cineo**, a premium cinematic streaming
/// app (Netflix / Disney+ / HBO Max mold). Self-contained per CONVENTIONS.md:
/// pure Flutter, the exact design font (Inter) is bundled under `fonts/`, and
/// the screen forces its own dark theme so it renders standalone when pushed as
/// a route. The wordmark and play mark are fully painted (no raster logo).
class StreamOnboardingSplashScreen extends StatefulWidget {
  const StreamOnboardingSplashScreen({super.key, this.onContinue});

  /// Optional tap / auto-advance callback used by the gallery to chain the flow.
  final VoidCallback? onContinue;

  static const String _font = 'Inter';

  // ── Cineo cinematic token palette (inline, shared across streaming screens) ──
  static const Color _bg = Color(0xFF0B0B0F);
  static const Color _brand = Color(0xFFE50914);
  static const Color _brandDark = Color(0xFFB00610);
  static const Color _text = Color(0xFFFFFFFF);
  static const Color _muted = Color(0xFF6B6B76);

  @override
  State<StreamOnboardingSplashScreen> createState() =>
      _StreamOnboardingSplashScreenState();
}

class _StreamOnboardingSplashScreenState
    extends State<StreamOnboardingSplashScreen>
    with SingleTickerProviderStateMixin {
  late final AnimationController _controller;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 1600),
    )..repeat();
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Theme(
      data: ThemeData.dark(useMaterial3: true),
      child: Scaffold(
        backgroundColor: StreamOnboardingSplashScreen._bg,
        body: GestureDetector(
          onTap: widget.onContinue,
          behavior: HitTestBehavior.opaque,
          child: SafeArea(
            child: SizedBox.expand(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.center,
                children: <Widget>[
                  const Spacer(),
                  const _CineoMark(size: 92),
                  const SizedBox(height: 26),
                  const Text(
                    'CINEO',
                    style: TextStyle(
                      fontFamily: StreamOnboardingSplashScreen._font,
                      fontSize: 34,
                      fontWeight: FontWeight.w800,
                      letterSpacing: 8,
                      color: StreamOnboardingSplashScreen._text,
                    ),
                  ),
                  const SizedBox(height: 10),
                  const Text(
                    'Movies & TV, unlimited',
                    style: TextStyle(
                      fontFamily: StreamOnboardingSplashScreen._font,
                      fontSize: 13.5,
                      fontWeight: FontWeight.w500,
                      letterSpacing: 0.4,
                      color: StreamOnboardingSplashScreen._muted,
                    ),
                  ),
                  const Spacer(),
                  // Brand-red shimmer loading bar.
                  SizedBox(
                    width: 132,
                    height: 3,
                    child: AnimatedBuilder(
                      animation: _controller,
                      builder: (BuildContext context, Widget? child) {
                        return CustomPaint(
                          painter: _ShimmerBarPainter(progress: _controller.value),
                        );
                      },
                    ),
                  ),
                  const SizedBox(height: 44),
                ],
              ),
            ),
          ),
        ),
      ),
    );
  }
}

/// The Cineo brandmark — a brand-gradient rounded square with a painted play
/// triangle punched out of a soft cinematic glow.
class _CineoMark extends StatelessWidget {
  const _CineoMark({required this.size});

  final double size;

  @override
  Widget build(BuildContext context) {
    return Container(
      width: size,
      height: size,
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(size * 0.28),
        gradient: const LinearGradient(
          begin: Alignment.topLeft,
          end: Alignment.bottomRight,
          colors: <Color>[
            StreamOnboardingSplashScreen._brand,
            StreamOnboardingSplashScreen._brandDark,
          ],
        ),
        boxShadow: <BoxShadow>[
          BoxShadow(
            color: StreamOnboardingSplashScreen._brand.withValues(alpha: 0.35),
            blurRadius: 32,
            spreadRadius: 2,
          ),
        ],
      ),
      child: CustomPaint(painter: _PlayMarkPainter()),
    );
  }
}

/// Paints a rounded white play triangle centered in the brandmark.
class _PlayMarkPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    final double w = size.width;
    final double h = size.height;
    final Paint paint = Paint()
      ..color = Colors.white
      ..style = PaintingStyle.fill
      ..strokeJoin = StrokeJoin.round
      ..strokeCap = StrokeCap.round
      ..strokeWidth = w * 0.12;
    final double cx = w * 0.54;
    final double cy = h * 0.5;
    final double r = w * 0.2;
    final Path tri = Path()
      ..moveTo(cx - r * 0.86, cy - r)
      ..lineTo(cx + r, cy)
      ..lineTo(cx - r * 0.86, cy + r)
      ..close();
    canvas.drawPath(
      tri,
      paint
        ..style = PaintingStyle.stroke,
    );
    canvas.drawPath(tri, paint..style = PaintingStyle.fill);
  }

  @override
  bool shouldRepaint(covariant _PlayMarkPainter oldDelegate) => false;
}

/// A brand-red gradient sweep that travels along a dim track — the splash
/// loading shimmer.
class _ShimmerBarPainter extends CustomPainter {
  const _ShimmerBarPainter({required this.progress});

  final double progress;

  @override
  void paint(Canvas canvas, Size size) {
    final RRect track = RRect.fromRectAndRadius(
      Offset.zero & size,
      Radius.circular(size.height / 2),
    );
    canvas.drawRRect(
      track,
      Paint()..color = const Color(0xFF2A2A33),
    );
    // Moving highlight window.
    final double windowW = size.width * 0.42;
    final double travel = (size.width + windowW) * progress - windowW;
    final Rect glow = Rect.fromLTWH(travel, 0, windowW, size.height);
    canvas.save();
    canvas.clipRRect(track);
    canvas.drawRect(
      glow,
      Paint()
        ..shader = const LinearGradient(
          colors: <Color>[
            Color(0x00E50914),
            StreamOnboardingSplashScreen._brand,
            Color(0x00E50914),
          ],
        ).createShader(glow),
    );
    canvas.restore();
  }

  @override
  bool shouldRepaint(covariant _ShimmerBarPainter oldDelegate) =>
      oldDelegate.progress != progress;
}

Plus bundled 1 binary asset (fonts/images). The CLI and MCP install those for you automatically.

Two faster ways to add it

Copy-paste works, but you can skip it entirely.

1. FlutterKit CLI

One command drops this screen — and its fonts — straight into your project.

$ flutterkit add stream-onboarding-splash

2. AI agent (MCP)

Connect FlutterKit's MCP server in Claude or Cursor and just ask your agent to install stream-onboarding-splash — it fetches and writes the files for you.

FAQ

Is this streaming splash screen free to use?

Yes. The full Dart source on this page is free to copy into your own projects, personal or commercial. Paste it directly, install it with the FlutterKit CLI (flutterkit add stream-onboarding-splash), or have an AI agent add it for you via MCP.

Does it need any external packages?

No — it's pure Flutter, built entirely on the material library with CustomPainter for the logo and shimmer. The only asset is the bundled Inter font, which you register in pubspec.yaml as shown in step 2. The CLI and MCP install that font file for you automatically.

Which Flutter version does it target?

It uses modern APIs like Color.withValues() and super parameters (super.key), so it targets Flutter 3.22+ (Dart 3). On an older SDK, swap _brand.withValues(alpha: 0.35) for _brand.withOpacity(0.35) and it will compile.

Related screens