Skip to main content
← back

app header

lib/app_header.dart · flutter · dart

an app header for flutter in one file. pixel wordmark, circular actions, pin or scroll as a sliver. no packages, no DI, no widget wrapping.

llms.txt

tldr

copy one file into your project, use it. no setup, no init call.

AppHeader(
  title: "trove",
  emphasis: " keep",
  actions: [
    AppHeaderActionButton(
      icon: Icons.palette_outlined,
      semanticLabel: "Theme",
      onTap: _openTheme,
    ),
  ],
)

pixel title + muted emphasis, solid page-colored bar, circular action buttons with a spring press. prefer .asSliver() inside a CustomScrollView.

live demo

starting the demo

install

copy one file

grab it and drop it in at lib/app_header.dart.

app_header.dart801 lines

it imports material, physics, and services from the flutter sdk. nothing goes in pubspec.yaml.

use it

there is no init call. the defaults stand on their own.

Scaffold(
  body: CustomScrollView(
    slivers: [
      AppHeader(title: "trove", emphasis: " keep").asSliver(pinned: true),
      SliverToBoxAdapter(child: content),
    ],
  ),
)

usage

pinned stays put. unpinned scrolls away with the content.

AppHeader(title: "trove").asSliver(pinned: true);
AppHeader(title: "trove").asSliver(pinned: false);

still works as a normal app bar when you only need the fixed case.

Scaffold(
  appBar: AppHeader(title: "trove", emphasis: " keep"),
  body: content,
)

trailing actions are circular buttons. pass icons or a custom child.

AppHeader(
  title: "trove",
  actions: [
    AppHeaderActionButton(
      icon: Icons.push_pin_outlined,
      semanticLabel: "Pin header",
      onTap: _togglePin,
    ),
    AppHeaderActionButton(
      icon: Icons.palette_outlined,
      semanticLabel: "Theme",
      endPadding: AppHeader.theme.sizing.actionEdgeGap,
      onTap: _openTheme,
    ),
  ],
)

optional leading mark. same chrome as the actions. hides when back is showing.

AppHeader(
  title: "trove",
  leading: const Icon(Icons.bolt, size: 18),
  leadingSemanticLabel: "Home",
  onLeadingTap: _goHome,
)

back shows when the route can pop. force it with showBack, or disable with automaticallyImplyLeading: false.

AppHeader(title: "Detail", showBack: true, onBack: () => Navigator.pop(context));

example

class HomeView extends StatefulWidget {
  const HomeView({super.key});

  @override
  State<HomeView> createState() => _HomeViewState();
}

class _HomeViewState extends State<HomeView> {
  bool _pinHeader = true;

  @override
  Widget build(BuildContext context) {
    final header = AppHeader(
      title: "trove",
      emphasis: " keep",
      actions: [
        AppHeaderActionButton(
          icon: _pinHeader ? Icons.push_pin : Icons.push_pin_outlined,
          semanticLabel: _pinHeader ? "Unpin header" : "Pin header",
          onTap: () => setState(() => _pinHeader = !_pinHeader),
        ),
      ],
    );

    return Scaffold(
      body: CustomScrollView(
        slivers: [
          header.asSliver(pinned: _pinHeader),
          SliverList.builder(
            itemCount: 40,
            itemBuilder: (_, i) => ListTile(title: Text("item $i")),
          ),
        ],
      ),
    );
  }
}

reference

everything below is here when you need it. you can ship without reading any of it.