Flutter Custom App Bar Design Patterns: From Large Titles to Immersive Headers

A practical guide to Flutter custom app bar design patterns with code: large titles, immersive media, search-first bars, contextual actions, and more.

ASOasis
8 min read
Flutter Custom App Bar Design Patterns: From Large Titles to Immersive Headers

Image used for representation purposes only.

Overview

A well-designed app bar anchors navigation, search, and key actions while reinforcing your brand. In Flutter, the built-in AppBar and SliverAppBar cover common needs, but real apps often require custom behavior: collapsing large titles, immersive media headers, contextual toolbars, or search-first layouts. This guide distills proven design patterns for building custom app bars that look sharp, adapt to content, and perform smoothly on both Android and iOS.

Anatomy of an App Bar in Flutter

Before customizing, understand the key pieces you can tune:

  • Leading: back button, menu icon, avatar, or brand mark.
  • Title: text, logo, or a search field; can be centered or aligned per platform.
  • Actions: icon buttons, overflow menus, or badges.
  • Flexible space: content behind the toolbar (e.g., imagery, gradients, large titles).
  • Bottom: typically a TabBar or a custom PreferredSizeWidget.
  • Behavior: elevation when scrolled under, pinned/floating/snap for slivers, and system UI overlays for status bar icons.

Pattern 1: Themed Small App Bar (Material 3-first)

Start with consistent theming so your app bars look right everywhere.

theme: ThemeData(
  useMaterial3: true,
  colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF3366FF)),
  appBarTheme: const AppBarTheme(
    elevation: 0,
    scrolledUnderElevation: 3, // adds depth only when content scrolls under
    centerTitle: false,        // Material: left-aligned; consider true for smaller titles
    backgroundColor: Colors.transparent, // allow surface tint to show
    surfaceTintColor: Colors.transparent,
    systemOverlayStyle: SystemUiOverlayStyle.dark, // status bar icons
  ),
)

Then use it:

class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Home'),
        leading: IconButton(
          icon: const Icon(Icons.menu),
          onPressed: () {/* open drawer */},
          tooltip: 'Open navigation menu',
        ),
        actions: const [
          _NotificationsAction(),
        ],
      ),
      body: const _ContentList(),
    );
  }
}

class _NotificationsAction extends StatelessWidget {
  const _NotificationsAction();
  @override
  Widget build(BuildContext context) {
    return Badge(
      label: const Text('2'),
      child: IconButton(
        icon: const Icon(Icons.notifications),
        onPressed: () {},
        tooltip: 'Notifications',
      ),
    );
  }
}

Tips

  • Prefer textScaleFactor-aware titles (no hard-coded widths).
  • Use tooltips on actions for accessibility.

Pattern 2: Collapsing Large Titles with Tabs (SliverAppBar.large)

For information-dense screens like libraries or catalogs, combine a large title that collapses into a compact toolbar and a TabBar.

class LibraryScreen extends StatelessWidget {
  const LibraryScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return DefaultTabController(
      length: 3,
      child: Scaffold(
        body: NestedScrollView(
          headerSliverBuilder: (context, innerBoxIsScrolled) => [
            SliverAppBar.large(
              title: const Text('Library'),
              pinned: true,
              forceElevated: innerBoxIsScrolled,
              bottom: const TabBar(
                isScrollable: true,
                tabs: [
                  Tab(text: 'All'),
                  Tab(text: 'Favorites'),
                  Tab(text: 'Downloads'),
                ],
              ),
            ),
          ],
          body: const TabBarView(
            children: [
              _List(tab: 'All'),
              _List(tab: 'Favorites'),
              _List(tab: 'Downloads'),
            ],
          ),
        ),
      ),
    );
  }
}

Notes

  • Use forceElevated with innerBoxIsScrolled to avoid visual jumps.
  • If you have multiple slivers above the TabBarView, add SliverOverlapAbsorber/Injector to fix overlap.

Pattern 3: Immersive, Transparent App Bar Over Media

Hero images, maps, or video often look best edge-to-edge. Make the app bar transparent and let content show through, while keeping status bar icons legible.

class DestinationScreen extends StatelessWidget {
  const DestinationScreen({super.key});
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      extendBodyBehindAppBar: true,
      appBar: AppBar(
        backgroundColor: Colors.transparent,
        elevation: 0,
        systemOverlayStyle: SystemUiOverlayStyle.light, // light icons over dark media
        title: const Text('Santorini'),
      ),
      body: Stack(
        children: [
          Positioned.fill(
            child: Image.asset('assets/santorini.jpg', fit: BoxFit.cover),
          ),
          // Add a top gradient for text legibility
          Positioned(
            top: 0,
            left: 0,
            right: 0,
            height: 160,
            child: IgnorePointer(
              child: DecoratedBox(
                decoration: BoxDecoration(
                  gradient: LinearGradient(
                    begin: Alignment.topCenter,
                    end: Alignment.bottomCenter,
                    colors: [Colors.black54, Colors.transparent],
                  ),
                ),
              ),
            ),
          ),
          // Content below
          ListView(
            padding: const EdgeInsets.only(top: kToolbarHeight + 32),
            children: const [
              ListTile(title: Text('Overview')),
              ListTile(title: Text('Itinerary')),
              ListTile(title: Text('Reviews')),
            ],
          )
        ],
      ),
    );
  }
}

Tips

  • Pair extendBodyBehindAppBar with a legibility gradient.
  • Switch systemOverlayStyle to dark if the media is light; consider a scroll listener to toggle dynamically.

Pattern 4: Custom Shape or Gradient App Bar (PreferredSizeWidget)

When branding demands non-rectangular shapes or layered gradients, compose your own PreferredSizeWidget.

class CurvedAppBar extends StatelessWidget implements PreferredSizeWidget {
  const CurvedAppBar({super.key, required this.title, this.actions});
  final Widget title;
  final List<Widget>? actions;

  @override
  Size get preferredSize => const Size.fromHeight(kToolbarHeight + 24);

  @override
  Widget build(BuildContext context) {
    final cs = Theme.of(context).colorScheme;
    return Material(
      color: Colors.transparent,
      child: Stack(children: [
        // Background with curve
        ClipPath(
          clipper: _BottomCurveClipper(),
          child: Container(
            height: preferredSize.height,
            decoration: BoxDecoration(
              gradient: LinearGradient(colors: [cs.primary, cs.tertiary]),
            ),
          ),
        ),
        SafeArea(
          bottom: false,
          child: SizedBox(
            height: kToolbarHeight,
            child: Row(
              children: [
                const BackButton(color: Colors.white),
                const SizedBox(width: 8),
                Expanded(child: DefaultTextStyle.merge(
                  style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600),
                  child: title,
                )),
                ...?actions,
                const SizedBox(width: 8),
              ],
            ),
          ),
        ),
      ]),
    );
  }
}

class _BottomCurveClipper extends CustomClipper<Path> {
  @override
  Path getClip(Size size) {
    final path = Path()
      ..lineTo(0, size.height - 24)
      ..quadraticBezierTo(size.width / 2, size.height, size.width, size.height - 24)
      ..lineTo(size.width, 0)
      ..close();
    return path;
  }
  @override
  bool shouldReclip(covariant CustomClipper<Path> oldClipper) => false;
}

Use it:

Scaffold(
  appBar: CurvedAppBar(
    title: const Text('Brand Story'),
    actions: [
      IconButton(icon: const Icon(Icons.share, color: Colors.white), onPressed: () {}),
    ],
  ),
  body: ...,
)

Pattern 5: Search-First Top Bar with SearchAnchor

Material 3’s SearchAnchor makes a search-dominant top bar simple without resorting to custom overlays.

class SearchScreen extends StatefulWidget {
  const SearchScreen({super.key});
  @override
  State<SearchScreen> createState() => _SearchScreenState();
}

class _SearchScreenState extends State<SearchScreen> {
  final SearchController _controller = SearchController();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: SearchAnchor.bar(
          searchController: _controller,
          barHintText: 'Search products',
          barLeading: const Icon(Icons.search),
          barTrailing: [
            IconButton(icon: const Icon(Icons.tune), onPressed: () {}),
          ],
          suggestionsBuilder: (context, controller) {
            final q = controller.text;
            final items = q.isEmpty ? const <String>[] : ['Shoes', 'Shirts', 'Shorts']
              .where((e) => e.toLowerCase().startsWith(q.toLowerCase()))
              .toList();
            return items.map((e) => ListTile(
              leading: const Icon(Icons.search),
              title: Text(e),
              onTap: () => controller.closeView(e),
            ));
          },
        ),
      ),
      body: const Placeholder(),
    );
  }
}

Pattern 6: Contextual Action Bar (Selection Mode)

Swap the normal app bar with a high-contrast contextual bar when users select items.

class PhotosScreen extends StatefulWidget {
  const PhotosScreen({super.key});
  @override
  State<PhotosScreen> createState() => _PhotosScreenState();
}

class _PhotosScreenState extends State<PhotosScreen> {
  final Set<int> selected = {};
  bool get selectionMode => selected.isNotEmpty;

  @override
  Widget build(BuildContext context) {
    final cs = Theme.of(context).colorScheme;
    final contextual = AppBar(
      backgroundColor: cs.inverseSurface,
      foregroundColor: cs.onInverseSurface,
      leading: IconButton(icon: const Icon(Icons.close), onPressed: () => setState(selected.clear)),
      title: Text('${selected.length} selected'),
      actions: [
        IconButton(icon: const Icon(Icons.select_all), onPressed: () {/* select all */}),
        IconButton(icon: const Icon(Icons.delete), onPressed: () {/* delete */}),
      ],
    );

    final normal = AppBar(
      title: const Text('Photos'),
      actions: [IconButton(icon: const Icon(Icons.search), onPressed: () {})],
    );

    return Scaffold(
      appBar: PreferredSize(
        preferredSize: const Size.fromHeight(kToolbarHeight),
        child: AnimatedSwitcher(
          duration: const Duration(milliseconds: 200),
          switchInCurve: Curves.easeOut,
          switchOutCurve: Curves.easeIn,
          child: selectionMode ? contextual : normal,
        ),
      ),
      body: GridView.builder(
        padding: const EdgeInsets.all(8),
        gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3),
        itemCount: 60,
        itemBuilder: (_, i) => GestureDetector(
          onLongPress: () => setState(() => selected.add(i)),
          onTap: () => setState(() => selected.contains(i) ? selected.remove(i) : selected.add(i)),
          child: Stack(children: [
            Positioned.fill(child: Container(color: Colors.grey[(i % 9 + 1) * 100])),
            if (selected.contains(i)) const Align(
              alignment: Alignment.topRight,
              child: Padding(
                padding: EdgeInsets.all(6),
                child: Icon(Icons.check_circle, color: Colors.lightBlue),
              ),
            ),
          ]),
        ),
      ),
    );
  }
}

Pattern 7: SliverAppBar with Stretch, Snap, and Floating

Make toolbars feel responsive to fast scrolling with floating and snap, and add pull-to-stretch effects.

CustomScrollView(
  slivers: [
    SliverAppBar(
      floating: true,
      snap: true,        // requires floating
      stretch: true,
      expandedHeight: 160,
      flexibleSpace: FlexibleSpaceBar(
        title: const Text('Discover'),
        stretchModes: const [
          StretchMode.zoomBackground,
          StretchMode.fadeTitle,
        ],
        background: Image.asset('assets/header.jpg', fit: BoxFit.cover),
      ),
    ),
    SliverList(
      delegate: SliverChildBuilderDelegate(
        (context, i) => ListTile(title: Text('Item $i')),
        childCount: 50,
      ),
    ),
  ],
)

Pattern 8: Platform-Adaptive Navigation Bars

Users expect native conventions. On iOS, prefer large titles with a collapsing Cupertino nav bar.

class AdaptivePage extends StatelessWidget {
  const AdaptivePage({super.key});

  @override
  Widget build(BuildContext context) {
    final isCupertino = Theme.of(context).platform == TargetPlatform.iOS ||
        Theme.of(context).platform == TargetPlatform.macOS;

    if (isCupertino) {
      return CupertinoPageScaffold(
        navigationBar: const CupertinoNavigationBar(
          middle: Text('Settings'),
          // Automatically handles back swipe and translucency
        ),
        child: const SafeArea(child: SettingsList()),
      );
    }

    return Scaffold(
      appBar: AppBar(title: const Text('Settings')),
      body: const SettingsList(),
    );
  }
}

If you need sliver-based large titles on iOS, use CupertinoSliverNavigationBar inside a CustomScrollView.

Pattern 9: Bottom App Bar + FAB Cutout

For apps where primary actions dominate, move key actions to a BottomAppBar and keep the top bar minimal.

Scaffold(
  appBar: AppBar(title: const Text('Compose')),
  floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
  floatingActionButton: FloatingActionButton(
    onPressed: () {},
    child: const Icon(Icons.add),
  ),
  bottomNavigationBar: BottomAppBar(
    shape: const CircularNotchedRectangle(),
    notchMargin: 8,
    child: Row(
      children: [
        IconButton(icon: const Icon(Icons.archive), onPressed: () {}),
        IconButton(icon: const Icon(Icons.mail), onPressed: () {}),
        const Spacer(),
        IconButton(icon: const Icon(Icons.attach_file), onPressed: () {}),
        IconButton(icon: const Icon(Icons.more_vert), onPressed: () {}),
      ],
    ),
  ),
)

Accessibility, Layout, and Localization Checklist

  • Touch targets: 48×48 logical pixels minimum for leading/actions.
  • Semantics: add semanticLabel and tooltips for icons; ensure badges announce changes.
  • Contrast: check title and icons over images/gradients; add overlays if needed.
  • Text scaling: test at 200% textScaleFactor; prefer Flexible widgets for titles.
  • RTL: respect Directionality; avoid hard-coded paddings that flip poorly.
  • Overflow: use softWrap: false and overflow: TextOverflow.ellipsis for long titles.

Performance Tips and Gotchas

  • Keep app bars as const where possible; avoid rebuilding icons on scroll.
  • For images in FlexibleSpaceBar, cache and downscale to device size to reduce jank.
  • scrolledUnderElevation only appears when the content beneath can scroll under; verify with NestedScrollView/CustomScrollView.
  • With NestedScrollView + TabBarView, prefer isScrollable tabs for many categories and test overscroll behavior.
  • Don’t block the system back gesture with full-width drag areas in the app bar.

Reusable Builder: One Place for Variants

Centralize your app bar rules so screens stay consistent.

class AppBars {
  static PreferredSizeWidget small({
    required String title,
    List<Widget>? actions,
    Widget? leading,
  }) => AppBar(
        title: Text(title),
        leading: leading,
        actions: actions,
      );

  static SliverAppBar large({
    required String title,
    PreferredSizeWidget? bottom,
    bool pinned = true,
  }) => SliverAppBar.large(
        title: Text(title),
        pinned: pinned,
        bottom: bottom,
      );

  static PreferredSizeWidget contextual({
    required String title,
    required VoidCallback onClose,
    List<Widget>? actions,
    required ColorScheme cs,
  }) => AppBar(
        backgroundColor: cs.inverseSurface,
        foregroundColor: cs.onInverseSurface,
        leading: IconButton(icon: const Icon(Icons.close), onPressed: onClose),
        title: Text(title),
        actions: actions,
      );
}

Testing and QA

  • Golden tests: capture app bar states (normal, scrolled, selection mode) to prevent regressions.
  • Device matrix: validate on small phones, large phones, and tablets in both orientations.
  • Theming: verify light/dark and high-contrast schemes; test systemOverlayStyle legibility.

Putting It All Together

Choose the smallest pattern that meets the need:

  • Content-first views: Immersive transparent app bar with overlay gradient.
  • Category hubs: SliverAppBar.large with tabs.
  • Power-user tasks: Contextual bars that appear only when needed.
  • Search-centric flows: SearchAnchor.bar.
  • Strong branding: PreferredSizeWidget with custom shapes/gradients.
  • Cross-platform polish: Material on Android, Cupertino on iOS.

By treating the app bar as a composable system—theme, behavior, layout, and platform—you’ll ship toolbars that feel native, scale with content, and showcase your brand without sacrificing usability or performance.

Related Posts