window_title

pub package pub points likes license: MIT

Set the window, browser tab and app switcher title at runtime — on web, Android, iOS, Linux, macOS and Windows — with a WindowTitle widget that follows your navigation instead of fighting over it.

// From anywhere.
setWindowTitle('My App');

// Or from the tree, where it belongs.
WindowTitle(title: 'Settings', child: SettingsScreen())

The tab now reads Settings · My App. Push a screen and it updates. Pop it and the previous one comes back. Open a dialog and it wins while it is open. No observers, no route names, no wiring.

Features

  • One title API for every platform. Window title on desktop, tab title on web, task description on Android, scene title on iOS.
  • Follows the navigator. Push, pop, dialogs, nested navigators, shell routes and tabs all do the obvious thing, because the winner comes from what was actually painted.
  • Composes into breadcrumbs. Details · Settings · My App, with a configurable separator, a segment limit and a custom composer if you want a different shape.
  • Live prefixes and suffixes. Unread counts, a "recording" dot, an elapsed clock — global decorations that survive navigation and rebuild nothing.
  • A ready-made ticker for elapsed time in the title, with the throttling and accessibility details already handled.
  • Testable. Swap the platform implementation and assert on the string.
  • Cheap. Resolution costs a few microseconds a frame, and the platform only hears about a title that actually changed.
  • Web-aware. Writes document.title directly, and at the right moment so the browser's back-button history menu gets correct per-entry labels.

Platform support

Android iOS Linux macOS Web Windows
Supported
Platform Mechanism Where it shows
Web document.title Tab and window title, bookmarks, history entries
Windows SetWindowTextW Title bar, taskbar, Alt-Tab, Task Manager
macOS NSWindow.title Title bar, Mission Control, Window menu, Dock window list
Linux gtk_window_set_title Title bar, taskbar, Alt-Tab, GNOME overview
Android TaskDescription + Activity.setTitle Mostly accessibility — see below
iOS UIWindowScene.title App switcher and Catalyst title bars — see below

Why not Flutter's Title widget?

Flutter ships Title, which calls SystemChrome.setApplicationSwitcherDescription. It is often described as Android-only; that is not quite right, and the real picture is worth knowing:

Title window_title
Web tab title ✅ — and it also rewrites <meta name="theme-color"> ✅ — title only
Android Recents entry ✅ — label only, leaving icon and colours alone
iOS ⛔ handled by the engine as an explicit no-op ✅ scene title
Linux, macOS, Windows ⛔ no handler at all; the call resolves to null ✅ real window title
Several in one tree last write wins resolved by what is actually on screen
Reverts when a route pops ⛔ no dispose, so nothing is ever undone
Composes into breadcrumbs

The engine implements that method on exactly two platforms. On iOS the handler is an empty method whose comment reads "No counterpart on iOS but is a benign operation", and the desktop embedders have no case for it — flutter/platform is an OptionalMethodChannel, so the call silently succeeds and does nothing.

Getting started

dependencies:
  window_title: ^0.1.0

There is nothing to initialise. Until your app asks for a title the package does nothing at all, so adding the dependency never blanks the <title> in your index.html or the one your desktop runner set.

Usage

Set the title imperatively

void main() {
  setWindowTitle('My App');
  runApp(const MyApp());
}

setWindowTitle is shorthand for WindowTitle.base, the app-level title. It is always the last segment, and the whole title when nothing else contributes one. Setting it again with the same value costs nothing.

Set it from the widget tree

WindowTitle(
  title: 'My App',
  child: MaterialApp(
    home: WindowTitle(
      title: 'Home',
      child: HomeScreen(),
    ),
  ),
)

Unlike Flutter's built-in Title, several of these can be alive at once. The one the user is looking at wins, and the titles enclosing it become the segments behind it.

The child is optional. Without one the widget takes up no space, which makes it easy to drop in wherever the title becomes known:

Column(
  children: [
    if (item != null) WindowTitle(title: item.name),
    // …
  ],
)

With go_router

go_router has no title concept, so put the widget in the route's builder. It is mounted exactly as long as the route is, so push and pop take care of themselves.

final router = GoRouter(
  routes: [
    GoRoute(
      path: '/items',
      builder: (_, _) => const WindowTitle(title: 'Items', child: ItemsScreen()),
      routes: [
        GoRoute(
          path: ':id',
          builder: (_, state) => WindowTitle(
            title: 'Item ${state.pathParameters['id']}',
            child: ItemScreen(id: state.pathParameters['id']!),
          ),
        ),
      ],
    ),
  ],
);

/items/7 reads Item 7 · Items · My App. Set WindowTitle.maxSegments = 2 to trim it, or mark a widget standalone: true to drop the enclosing titles while keeping the app name.

A ShellRoute or StatefulShellRoute works the same way. Inactive branches stay mounted but off-stage, and off-stage widgets never compete for the title.

Refine a title once data arrives

An empty title contributes nothing, so the enclosing one keeps showing while you wait:

FutureBuilder<Item>(
  future: _item,
  builder: (context, snapshot) => WindowTitle(
    title: snapshot.data?.name ?? '',
    standalone: snapshot.hasData,
    child: ItemView(snapshot: snapshot),
  ),
)

Prefixes, suffixes and overrides

For app-wide state that should be visible on every screen — an unread count, an unsaved-changes dot, a connection warning — decorate the composed title instead of replacing it:

WindowTitle.prefix = unread > 0 ? '($unread) ' : '';
WindowTitle.suffix = ' [staging]';

Or declaratively:

WindowTitleDecorator(prefix: isDirty ? '• ' : null, child: editor)

If several parts of the app decorate at once, WindowTitle.addDecoration() gives each one its own handle and priority, so removing one leaves the others alone. And WindowTitle.overrideTitle replaces the composed title outright, with prefixes still applying on top.

A live clock in the title

WindowTitleTicker puts elapsed time in front of whatever the title currently is:

final ticker = WindowTitleTicker();

ticker.start();   // 00:00:00 · Home · My App
ticker.stop();

It handles the parts that are easy to get wrong:

  • Elapsed time comes from the clock, not from counting ticks. Browsers throttle timers in a hidden tab — to once a second, and to once a minute after five minutes hidden — so ticks get skipped. A skipped tick makes the display coarser, never wrong.
  • Nothing rebuilds. A tick recomposes a few strings and touches the platform only if the result changed.
  • It slows down for screen readers. A title changing every second is auto-updating content under WCAG SC 2.2.2, and some screen readers speak the document title when it changes. While the platform reports accessible navigation, the cadence drops to accessibleInterval — one minute by default, or null to opt out.
  • It decorates rather than replaces, so the title still says what the screen is. A bare 00:12:34 would fail WCAG 2.4.2.

Custom cadence and format:

WindowTitleTicker(
  interval: const Duration(minutes: 1),
  builder: (elapsed) => '${elapsed.inMinutes}m · ',
)

How the title is chosen

Exactly one mounted WindowTitle is the leaf. Its title, followed by the titles of the widgets enclosing it, becomes the segment list. The leaf is picked by these rules, in order:

  1. What is on screen wins. Every WindowTitle painted in the last frame is ranked front to back from the layer tree — the same mechanism Flutter uses to decide which AnnotatedRegion<SystemUiOverlayStyle> controls the status bar. A dialog beats the screen behind it, a pushed route beats the one it covers, and off-stage routes, inactive shell branches and hidden tabs do not compete.
  2. A WindowTitle with a child is as visible as that child; one without a child is as visible as the WindowTitle enclosing it. A bare marker paints nothing of its own, so it keeps counting as part of its screen even if a ListView culls it. A WindowTitle wrapping an off-stage route drops out, even when nothing on screen offers a title to replace it.
  3. Deeper wins, then later wins. Between two equally visible widgets, the more deeply nested one is the more specific one; ties go to whichever mounted last.

Resolution runs once per frame in a few microseconds, and the platform is only told about a title that actually changed.

Two things help when a title is not what you expected: WindowTitle.debugComposition() returns everything that went into it, and WindowTitle.current is a ValueListenable<String> of what the platform was last given.

API overview

API Default Purpose
setWindowTitle(String) Shorthand for WindowTitle.base.
WindowTitle(title:, child:, standalone:) Contributes a title while mounted.
WindowTitle.base '' App-level title; always the last segment.
WindowTitle.prefix / .suffix '' Wraps the composed title.
WindowTitle.overrideTitle null Replaces the composed title.
WindowTitle.separator ' · ' Joins segments.
WindowTitle.maxSegments null Keeps at most N segments, most specific first.
WindowTitle.composer null Replaces the joining logic.
WindowTitle.current ValueListenable<String> of the applied title.
WindowTitle.addDecoration() A prefix/suffix/override with a priority and a handle.
WindowTitleDecorator The same, as a widget.
WindowTitleTicker An elapsed-time prefix with an accessible cadence.
WindowTitle.debugComposition() Everything that went into the current title.
WindowTitlePlatform.instance Swap the backend, mostly for tests.

Repeated adjacent segments are collapsed, so setting WindowTitle.base = 'My App' and wrapping the app in WindowTitle(title: 'My App', …) shows My App, not My App · My App.

Platform notes

Web

MaterialApp(title: …) conflicts with this package. As the table above shows, Flutter's Title widget really does write document.title on the web — and the classic MaterialApp constructor defaults title to the empty string, so it blanks the <title> from your index.html at startup. The same engine handler also rewrites <meta name="theme-color">. Pass title: null, or use MaterialApp.router, which already defaults to null. In debug builds this package prints a one-time note if it spots the conflict; silence it with WindowTitle.debugWarnAboutConflicts = false.

The back button's history menu. history.pushState() takes a title argument, but the HTML spec names it unused and no browser reads it. Browsers instead stamp the current document.title onto a history entry as it is created — so the title has to be written after the navigation, or you rename the entry the user is leaving and the new one inherits that wrong title. This package defers its write by one event-loop task for exactly that reason, which puts it after the pushState that Flutter's Router schedules. Long-pressing the back button shows the right label per entry, with no work on your part.

Embedded and multi-view apps. document.title belongs to the page, not to a FlutterView, so an app embedded in someone else's page should not be touching it. Hand the title to the host instead:

WindowTitlePlatform.instance = const NoopWindowTitlePlatform();
WindowTitle.current.addListener(() => tellTheHost(WindowTitle.current.value));

Android

The honest answer is "not much is visible". TaskDescription.label is the only per-app title Android exposes, and on current stock Android the Recents card shows the manifest's android:label instead: in AOSP's Launcher3 the label feeds the card's content description, so what it really changes is what TalkBack reads out. The window title set alongside it lands in AccessibilityWindowInfo.getTitle(), which TalkBack announces for the window. Worth having, but do not expect a Pixel's Recents card to change. Desktop windowing on Android 15/16 does not use it either — the caption comes from the application label, with no API to change it.

A ticking title there costs a synchronous binder transaction per change for almost nothing visible, so consider stopping the ticker on Android.

iOS

UIScene.title is documented as a way to tell an app's scenes apart, so it earns its keep on iPadOS multi-window and Stage Manager, and on Mac Catalyst and Apple silicon Macs, where Apple documents it as becoming the window's title bar. A single-scene iPhone app has no scenes to tell apart and keeps showing the app's name.

Desktop

The window title reaches the title bar, the taskbar or Dock, and the window switcher. What no platform lets an app change at runtime — and this package therefore does not pretend to — is the macOS menu-bar and Dock app name (CFBundleName) or the Android launcher label (android:label).

Testing

Swap the platform implementation and assert on what it was handed:

class FakeWindowTitlePlatform extends WindowTitlePlatform {
  final List<String> titles = [];
  @override
  void setTitle(String title) => titles.add(title);
}

void main() {
  late FakeWindowTitlePlatform platform;

  setUp(() {
    WindowTitle.debugReset();
    WindowTitlePlatform.instance = platform = FakeWindowTitlePlatform();
  });

  tearDown(() {
    WindowTitle.debugReset();
    WindowTitlePlatform.resetInstance();
  });

  testWidgets('the settings screen names itself', (tester) async {
    await tester.pumpWidget(const MyApp());
    await tester.pump();
    expect(platform.titles.last, 'Settings · My App');
  });
}

WindowTitle.debugReset() clears every global so tests stay independent. Remember await tester.pump() — the title is computed after the frame.

Known behaviour and limits

  • During a pop animation the outgoing route keeps the title until the transition finishes, because it is still the thing painted on top. Pushes update immediately.
  • One title per app. Desktop multi-window and web multi-view are not modelled; every WindowTitle across every view feeds a single title. On the web that is inherent — document.title is a page-level property.
  • RenderObject.layer is read directly to get the paint order. It is @protected in the framework and this package suppresses that lint deliberately: it is the same call RenderView._updateSystemChrome makes on every composited frame, and debugLayer is null in release builds.

Additional information

The example is a small app that exercises every feature; example/lib/main_smoke.dart is a scripted run for watching a real window title from outside the process, including headlessly on Linux under Xvfb.

Issues and pull requests are welcome on the issue tracker.

License

MIT — see LICENSE.

Libraries

window_title
Dynamic window, tab and app-switcher titles for every Flutter platform.