window_title 1.0.0
window_title: ^1.0.0 copied to clipboard
Set the window, browser tab and app switcher title at runtime, with a WindowTitle widget that follows your navigation.
window_title #
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.titledirectly, 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, ornullto opt out. - It decorates rather than replaces, so the title still says what the screen is. A bare
00:12:34would 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:
- What is on screen wins. Every
WindowTitlepainted in the last frame is ranked front to back from the layer tree — the same mechanism Flutter uses to decide whichAnnotatedRegion<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. - A
WindowTitlewith achildis as visible as that child; one without achildis as visible as theWindowTitleenclosing it. A bare marker paints nothing of its own, so it keeps counting as part of its screen even if aListViewculls it. AWindowTitlewrapping an off-stage route drops out, even when nothing on screen offers a title to replace it. - 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
WindowTitleacross every view feeds a single title. On the web that is inherent —document.titleis a page-level property. RenderObject.layeris read directly to get the paint order. It is@protectedin the framework and this package suppresses that lint deliberately: it is the same callRenderView._updateSystemChromemakes on every composited frame, anddebugLayeris 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.