---
title: "App bar"
description: "Names the current screen at its top edge and holds the way back and a few of the screen's actions. Its bottom edge stands as the edge of the screen's header, and it takes its scrolled look once content scrolls under it."
url: "https://tanrei.chnotchy.com/components/app-bar/"
last_updated: 2026-10-23T21:17:12.000Z
---

# App bar

> Names the current screen at its top edge and holds the way back and a few of the screen's actions. Its bottom edge stands as the edge of the screen's header, and it takes its scrolled look once content scrolls under it.

## Overview

The Web and React samples show the current spec, in which the bar’s items are icon-only [Buttons](https://tanrei.chnotchy.com/components/button/). SwiftUI, Compose, Flutter and React Native are lagging: they still ship the previous revision, with an icon button component of its own, and their samples show that revision until they catch up.

### Usage

An app bar is a layout region, not an overlay. It takes its height at the top of the screen, the content starts below it, and the bar itself never takes focus.

It holds two kinds of item: one `navigation` item at the start (back, menu or close) and up to three `action` items at the end. Both are your own icon-only [Buttons](https://tanrei.chnotchy.com/components/button/), each with its own label and its own click or, where it leads somewhere, its own `href`. The bar lays them out and never restyles them. Each platform takes them in its own slots: children with `slot="navigation"` and `slot="actions"` on the web, the `navigation` and `actions` view builders on SwiftUI, the `navigationIcon` and `actions` lambdas on Compose, `leading` and `actions:` on Flutter, and the `navigation` and `actions` nodes on React Native.

The title is the screen’s name and its heading. On the web it is `headline`, because HTML’s global `title` attribute would put a tooltip on the whole bar.

**Scrolled.** While content lies under the bar, the bar takes its scrolled look. Connect the screen’s scrolling view and the bar sets `scrolled` itself: `scroll-target` on the web (the document by default), `.tanreiAppBarScrollSource()` on SwiftUI (applied after the `.safeAreaInset` that holds the bar), a `scrollBehavior` connected with `Modifier.nestedScroll` on Compose, the page’s scroll notifications on Flutter, and `useAppBarScroll()` on React Native. Without a connection, set `scrolled` yourself.

**System bars.** The bar’s color reaches under the status bar, the camera cutout or the window caption above it. Its content stays below them and inside the side insets. The web reads `env(safe-area-inset-*)`, Compose takes `windowInsets` (the status bars and the horizontal display cutout by default), Flutter reads `MediaQuery` padding, and SwiftUI places the bar with `.safeAreaInset(edge: .top)`. React Native has no safe-area API, and Tanrei takes no safe-area dependency, so the insets come from props only. Give `TanreiProvider` the window’s `insets` (if you use `react-native-safe-area-context`, pass its `useSafeAreaInsets()` through). A bar’s own `insets` prop replaces the sides it names, so a bar that is not at the top of the window passes `{ top: 0 }`.

**Web**

```html
<!-- The title is headline: HTML's title attribute would show a tooltip over the bar. -->
<tanrei-app-bar headline="Inbox">
  <tanrei-button variant="text" slot="navigation" label="Back"><span slot="icon">arrow_back</span></tanrei-button>
  <tanrei-button variant="text" slot="actions" label="Search"><span slot="icon">search</span></tanrei-button>
  <tanrei-button variant="text" slot="actions" label="More options"><span slot="icon">more_vert</span></tanrei-button>
</tanrei-app-bar>

<!-- A large title that collapses as #inbox scrolls. The title is an h1 unless heading-level says otherwise. -->
<div style="display: flex; flex-direction: column; block-size: 100dvh">
  <tanrei-app-bar size="large" headline="Inbox" scroll-target="inbox">
    <tanrei-button variant="text" slot="navigation" label="Menu"><span slot="icon">menu</span></tanrei-button>
  </tanrei-app-bar>
  <main id="inbox" style="flex: 1; overflow: auto">…</main>
</div>

<!-- A button that leads to a page is a link: give it an href. A search can be an action: its icon trigger. -->
<tanrei-app-bar headline="Tabs">
  <tanrei-button variant="text" slot="navigation" href="/components/" label="All components"><span slot="icon">arrow_back</span></tanrei-button>
  <tanrei-search slot="actions" bar="icon" presentation="dialog" label="Search the docs" placeholder="Search"
    clear-label="Clear search" back-label="Back"></tanrei-search>
</tanrei-app-bar>

<script type="module">
  import '@tanrei/web/components/app-bar';
  import '@tanrei/web/components/button';
  import '@tanrei/web/components/search';
  // The buttons are yours: the bar adds no events of its own.
  document.querySelector('[label="Back"]').addEventListener('click', () => history.back());
</script>
```

**React**

```tsx
import { AppBar, Button, Search } from '@tanrei/react';

<AppBar headline="Inbox">
  <Button variant="text" slot="navigation" label="Back" onClick={goBack}><span slot="icon">arrow_back</span></Button>
  <Button variant="text" slot="actions" label="Search" onClick={search}><span slot="icon">search</span></Button>
  <Button variant="text" slot="actions" label="More options" onClick={openMore}><span slot="icon">more_vert</span></Button>
</AppBar>

// A button that leads to a page is a link: give it an href. A search can be an action: its icon trigger.
<AppBar headline="Tabs">
  <Button variant="text" slot="navigation" href="/components/" label="All components"><span slot="icon">arrow_back</span></Button>
  <Search slot="actions" bar="icon" presentation="dialog" label="Search the docs" placeholder="Search" clearLabel="Clear search" backLabel="Back" />
</AppBar>
```

**SwiftUI**

```swift
import Tanrei

// The bar sits in the top safe-area inset of a NavigationStack whose system bar is hidden.
// isScrolled left nil follows the scroll view marked .tanreiAppBarScrollSource().
NavigationStack {
    ScrollView { InboxRows() }
        .safeAreaInset(edge: .top, spacing: 0) {
            TanreiAppBar("Inbox", size: .large) {
                TanreiIconButton("Menu", action: openMenu) { Image(systemName: "line.3.horizontal") }
            } actions: {
                TanreiIconButton("Search", action: search) { Image(systemName: "magnifyingglass") }
            }
        }
        // After the inset that holds the bar: a modifier applied inside it cannot reach the bar.
        .tanreiAppBarScrollSource()
        .toolbar(.hidden, for: .navigationBar)
}
```

**Compose**

```kotlin
import io.github.chnotchy.tanrei.components.TanreiAppBar
import io.github.chnotchy.tanrei.components.TanreiAppBarDefaults
import io.github.chnotchy.tanrei.components.TanreiIconButton
import io.github.chnotchy.tanrei.primitives.TanreiText
import io.github.chnotchy.tanrei.tokens.TanreiAppBarSize

// A small bar. Without a scrollBehavior it stays flat.
TanreiAppBar(
    title = "Inbox",
    navigationIcon = { TanreiIconButton(onClick = onBack, contentDescription = "Back") { TanreiText("arrow_back") } },
    actions = {
        TanreiIconButton(onClick = onSearch, contentDescription = "Search") { TanreiText("search") }
        TanreiIconButton(onClick = onMore, contentDescription = "More options") { TanreiText("more_vert") }
    },
)

// A large bar that collapses as the list scrolls: connect the behavior on a parent of the scrolling view.
// pinnedScrollBehavior() is a small bar's: it never collapses, and turns scrolled while content is under it.
val scrollBehavior = TanreiAppBarDefaults.collapseScrollBehavior()
Column(Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)) {
    TanreiAppBar(title = "Inbox", size = TanreiAppBarSize.Large, scrollBehavior = scrollBehavior)
    LazyColumn { /* the screen's content */ }
}
```

**Flutter**

```dart
import 'package:tanrei/tanrei.dart';

// A small bar, in a scaffold's app bar slot or first in a Column.
// scrolledUnder left null follows the nearest ScrollNotificationObserver, as AppBar does.
TanreiAppBar(
  leading: TanreiIconButton(onPressed: () => Navigator.of(context).maybePop(), icon: const Icon(AppSymbols.arrowBack), semanticLabel: 'Back'),
  title: 'Inbox',
  actions: <Widget>[
    TanreiIconButton(onPressed: search, icon: const Icon(AppSymbols.search), semanticLabel: 'Search'),
    TanreiIconButton(onPressed: openMore, icon: const Icon(AppSymbols.moreVert), semanticLabel: 'More options'),
  ],
),

// A large bar that collapses with its content is a sliver, pinned at the top of its CustomScrollView.
CustomScrollView(
  slivers: <Widget>[
    TanreiSliverAppBar(title: 'Inbox', size: TanreiAppBarSize.large, leading: menuButton),
    SliverList.list(children: messages),
  ],
),
```

**React Native**

```tsx
import { useState } from 'react';
import { ScrollView, View } from 'react-native';
import { AppBar, IconButton, useAppBarScroll } from '@tanrei/react-native';

// The insets come from TanreiProvider's insets prop, or from the bar's own.
<AppBar
  title="Inbox"
  navigation={<IconButton accessibilityLabel="Back" onPress={goBack}>arrow_back</IconButton>}
  actions={
    <>
      <IconButton accessibilityLabel="Search" onPress={search}>search</IconButton>
      <IconButton accessibilityLabel="More options" onPress={openMore}>more_vert</IconButton>
    </>
  }
/>

// A large bar that collapses: connect the scroll view, lay the bar over it, and pad the content by the bar's height.
const scroll = useAppBarScroll();
const [barHeight, setBarHeight] = useState(0);
<View style={{ flex: 1 }}>
  <ScrollView onScroll={scroll.onScroll} scrollEventThrottle={16} contentContainerStyle={{ paddingTop: barHeight }}>
    {messages}
  </ScrollView>
  <View style={{ position: 'absolute', top: 0, left: 0, right: 0 }} onLayout={(e) => setBarHeight((h) => Math.max(h, e.nativeEvent.layout.height))}>
    <AppBar size="large" title="Inbox" scrollState={scroll.state} navigation={menuButton} />
  </View>
</View>
```

## Specs

- Category: Navigation
- Level: base
- Listing: default
- Page: https://tanrei.chnotchy.com/components/app-bar/
- Spec: https://github.com/chnotchy/tanrei/blob/main/specs/app-bar.spec.json

### Availability

| Design system | Has it | Notes |
| --- | --- | --- |
| Tanrei (`tanrei`) | Yes | — |
| Cupertino (Apple HIG-inspired) (`cupertino`) | Yes | — |
| M3 Expressive-like (Material 3 Expressive-inspired) (`m3-expressive-like`) | Yes | — |
| M3-like (Material 3-inspired) (`m3-like`) | Yes | — |

### Platforms

- Web: the element `<tanrei-app-bar>`, registered by importing `@tanrei/web` (or only its own module, `@tanrei/web/components/app-bar`).
- React: `AppBar` from `@tanrei/react`.

| Platform | Name | Status |
| --- | --- | --- |
| Web | `tanrei-app-bar` | lagging |
| SwiftUI | `TanreiAppBar` | lagging |
| Jetpack Compose | `TanreiAppBar` | lagging |
| Flutter | `TanreiAppBar` | lagging |
| React Native | `AppBar` | lagging |

Names a platform gives in its own idiom:

| Spec | Web | SwiftUI | Jetpack Compose | Flutter | React Native |
| --- | --- | --- | --- | --- | --- |
| `title` (prop) | `headline` | `title` | `title` | `title` | `title` |
| `titleAlignment` (prop) | `titleAlignment` | `titleAlignment` | `titleAlignment` | `centerTitle` | `titleAlignment` |
| `scrolled` (prop) | `scrolled` | `isScrolled` | `scrollBehavior` | `scrolledUnder` | `scrolled` |

Statuses: `planned` — In the inventory. Nothing shippable exists yet. `lagging` — An implementation of an earlier spec exists; it has not caught up with the current one. `alpha` — Implemented with tests. The API may still change. `beta` — Feature complete and at parity. Only fixes are expected. `stable` — Covered by semantic versioning. `deprecated` — Still shipped; scheduled for removal.

### Anatomy

- `container`: The bar across the top of the screen. Its color reaches under the system inset above it (status bar, camera cutout, window caption).
- `navigation` (optional): The navigation item: one icon-only Button at the start (back, menu or close). The product's icon-only Button, not restyled by the bar.
- `brand` (optional): The product's brand: one link to its first page (or one image), after the navigation item, in a cell as large as an icon button's. It draws one image, the symbol alone, the wordmark alone, the symbol and the wordmark side by side (lockup), or one of the two by the window's width (responsive).
- `context` (optional): The product's context: one control after the brand (a workspace, a project, a design system), the product's own Button, not restyled by the bar.
- `title`: The screen's name. In the top row for a small bar; under the top row for an expanded large bar, moving into the top row as it collapses.
- `subtitle` (optional): One line under the title: context for it (an account, a status, when it was edited).
- `actions` (optional): The action items: up to three icon-only Buttons at the end. The product's icon-only Buttons, not restyled by the bar.
- `outline`: The bar's bottom edge. In the default system it is drawn at rest and while content is under the bar; dividerVisibility scroll draws it only once content has scrolled under the bar.

### Props

| Prop | Type | Default | Web attribute | Description |
| --- | --- | --- | --- | --- |
| `size` | `enum`: `small`, `large` | `"small"` | `size` | Small puts the title in the one row the bar has; large gives the title a larger row of its own under the top row, which collapses into the top row as the content scrolls. One product uses small on most screens and large on its top-level ones. A design system may add values. |
| `title` | `string` | `""` | — | The screen's name, and the heading of the screen for assistive technology. |
| `subtitle` | `string` | `""` | `subtitle` | One line under the title. Empty draws none. |
| `titleAlignment` | `enum`: `start`, `center` | `"start"` | `title-alignment` | Where the title sits in the top row: after the navigation button, or centered on the bar. An expanded large bar's own title row always starts at the start. |
| `brandHref` | `string` | `""` | `brand-href` | The address of the product's first page: the brand is a link to it. Empty draws the brand as an image that is not a link. |
| `brandLabel` | `string` | `""` | `brand-label` | The product's name: the brand's one accessible name, whichever of its images is shown. Required wherever the bar has a brand. |
| `brandLayout` | `enum`: `lockup`, `responsive` | `"lockup"` | `brand-layout` | How the brand's symbol and wordmark stand when the product gives both (APB-21): lockup sets them side by side, comp.app-bar.brand.lockup.gap apart and aligned as comp.app-bar.brand.lockup.alignment says; responsive shows the wordmark while the window is medium or wider and the symbol while it is compact, never both. One image, a symbol alone or a wordmark alone is shown as it is, at every width, under either value. |
| `scrolled` | `boolean` | `false` | `scrolled` | Whether content lies under the bar. A scrolling view connected to the bar sets it (see platforms); a product without one sets it itself. It selects the bar's scrolled condition, and a large bar is drawn collapsed while it is true. |
| `dividerVisibility` | `enum`: `auto`, `always`, `scroll` | `"auto"` | `divider-visibility` | When the bar's bottom edge is drawn. always: at rest and while content is under the bar, so that the edge of the screen's header stands still (an app shell's header, a screen whose content starts right under the bar). scroll: only while content lies under the bar, so that the line itself says the content has moved (the scrolled condition's edge alone). auto takes comp.app-bar.outline.visibility, the design system's answer (always in the default system). The bar's color and elevation follow scrolled under either choice. |

### Slots

| Slot | Description |
| --- | --- |
| `navigation` | Holds the navigation item: one icon-only Button at the start. |
| `brand` | The brand as one image: a product's single logo file, drawn comp.app-bar.brand.image.height high. Takes the place of brand-symbol and brand-wordmark where both are given. |
| `brand-symbol` | The brand's symbol: comp.app-bar.brand.symbol.size high alone, or while the window is compact under brandLayout responsive; comp.app-bar.brand.lockup.symbol.size high (the wordmark's height) beside the wordmark in a lockup. |
| `brand-wordmark` | The brand's wordmark, drawn comp.app-bar.brand.wordmark.height high: alone at every width, after the symbol in a lockup, or while the window is medium or wider under brandLayout responsive. |
| `context` | Holds one control of the product's context after the brand: a Button, or a Button that opens a Menu, that says and changes the workspace, the project or the design system the screen is in. The product's own control, not restyled by the bar. |
| `actions` | Holds the action items, up to three icon-only Buttons at the end, in reading order; more belong in an overflow menu the product provides. |

### Item: `navigation`

The one icon-only Button at the start of the top row (back, menu or close): a Button of the text variant and the medium size with an icon and no label, unless the product sets otherwise, placed in the navigation slot, which the bar lays out and does not restyle.

It is a Button.

Names: Web `tanrei-button`, SwiftUI `TanreiButton`, Jetpack Compose `TanreiButton`, Flutter `TanreiButton`, React Native `Button`.

| Prop | Type | Default | Web attribute | Description |
| --- | --- | --- | --- | --- |
| `label` | `string` | `""` | `label` | Its accessible name ("Back"), as the Button's label. Required. |

| Event | Description |
| --- | --- |
| `click` | The button was activated, as the Button's click. |

| Slot | Description |
| --- | --- |
| `icon` | Its glyph, as the Button's icon slot. A fixture item's content is this glyph. |

### Item: `action`

An icon-only Button at the end of the top row, up to three in reading order: a Button of the text variant and the medium size with an icon and no label, unless the product sets otherwise, placed in the actions slot, which the bar lays out and does not restyle.

It is a Button.

Names: Web `tanrei-button`, SwiftUI `TanreiButton`, Jetpack Compose `TanreiButton`, Flutter `TanreiButton`, React Native `Button`.

| Prop | Type | Default | Web attribute | Description |
| --- | --- | --- | --- | --- |
| `label` | `string` | `""` | `label` | Its accessible name ("Search"), as the Button's label. Required. |

| Event | Description |
| --- | --- |
| `click` | The button was activated, as the Button's click. |

| Slot | Description |
| --- | --- |
| `icon` | Its glyph, as the Button's icon slot. A fixture item's content is this glyph. |

### States

`enabled`

### Accessibility

- Role: `none`
- Labeling: The title is the screen's heading (a level 1 heading on the web unless the product sets another level). The subtitle is read after it. The navigation and action items, and the control in context, are named by their own labels. The brand is one link to the product's first page named by brandLabel (the product's name), whichever of its images is shown; its images are hidden from assistive technology.
- Minimum target size: yes

| Keys | Action |
| --- | --- |
| Tab | Moves focus to the navigation item, then to the brand's link, then to the control in context, then through the action items; the bar itself is not a tab stop. |

- The app bar has no role of its own and takes no input: its buttons are the product's icon-only Buttons, which meet the minimum target themselves (BTN-5). The bar is not a landmark, because only the product knows whether it is its page's banner; a product that wants one wraps the bar in its own header element.
- The title is truncated rather than wrapped even at 200% text size: the bar's rows grow with the text, but a bar of several lines would push the screen's content away. The full title stays available to assistive technology.
- Where both titles of a large bar are partly visible, only the expanded one is exposed, so the heading is never read twice.

### Behaviour

Requirements in EARS form, each with its stable id.

- **APB-1** WHILE scrolled is false THE app bar SHALL fill its container with comp.app-bar.flat.container.color, raise it by comp.app-bar.flat.container.elevation and draw its bottom edge comp.app-bar.outline.width thick: in comp.app-bar.scrolled.outline.color WHILE its divider visibility (APB-18) is always, so that the edge stands still whether or not content lies under the bar, and in comp.app-bar.flat.outline.color WHILE it is scroll.
- **APB-2** WHILE scrolled is true THE app bar SHALL fill its container with comp.app-bar.scrolled.container.color, raise it by comp.app-bar.scrolled.container.elevation and draw its bottom edge comp.app-bar.outline.width thick in comp.app-bar.scrolled.outline.color, under either divider visibility (APB-18).
- **APB-3** WHEN scrolled changes THE app bar SHALL change its container color, elevation and bottom edge over comp.app-bar.motion.condition-duration with comp.app-bar.motion.condition-easing, and at once where motion is reduced; WHILE its divider visibility (APB-18) is always the bottom edge SHALL NOT change.
- **APB-4** THE app bar SHALL span the full width of its parent and lay out a top row at least comp.app-bar.container.height.small high: the navigation item comp.app-bar.container.padding-inline from the start edge, the title block, and the action items comp.app-bar.container.padding-inline from the end edge, with comp.app-bar.container.gap between them and comp.app-bar.actions.gap between two actions, each vertically centered in the row; a navigation item SHALL go to the start and action items to the end wherever they stand among the items; WHILE there is no navigation item the title block SHALL start comp.app-bar.title.inset from the start edge, and WHILE there are no action items it SHALL end comp.app-bar.title.inset from the end edge. Right-to-left layouts SHALL mirror the order.
- **APB-5** THE app bar SHALL draw a title in the top row on one line in comp.app-bar.title.typescale.small and comp.app-bar.title.color, and WHILE subtitle is not empty the subtitle on one line below it in comp.app-bar.subtitle.typescale.small and comp.app-bar.subtitle.color, the two vertically centered as a block; each SHALL be truncated at the end with an ellipsis when it does not fit. THE app bar SHALL set the title's letter-spacing, in every size, by the script of its text, as every title is set (PRS-20): no negative letter-spacing WHERE its language is Chinese, Japanese or Korean, and none WHERE its script joins its letters or stacks marks on them.
- **APB-6** WHILE titleAlignment is center THE app bar SHALL center the top row's title block on the bar's full width and narrow it equally on both sides, truncating its text, so that it never overlaps the navigation or actions slots, and SHALL center its text; WHILE titleAlignment is start THE app bar SHALL start the title block after the navigation slot (or at comp.app-bar.title.inset) with its text start-aligned.
- **APB-7** WHILE size is large and the bar is expanded THE app bar SHALL be at least comp.app-bar.container.height.large high, SHALL draw no title in its top row, and SHALL draw below the top row the title in comp.app-bar.title.typescale.large and, WHILE subtitle is not empty, the subtitle in comp.app-bar.subtitle.typescale.large, both starting comp.app-bar.title.inset from the start edge, each on one line truncated at the end, the block ending comp.app-bar.container.padding-block-end above the bar's bottom edge.
- **APB-8** WHILE size is large and a scrolling view is connected THE app bar SHALL shrink by the distance the content has scrolled from its top, one for one, from its expanded height to comp.app-bar.container.height.small, the expanded title block moving up with the bar's bottom edge and clipped under the top row; the top row's title SHALL be invisible while the expanded title is wholly visible, fully visible once the bar has collapsed, and in between fade in linearly with the part of the expanded title that is covered. WHEN the content scrolls back toward its top THE app bar SHALL expand the same way.
- **APB-9** WHERE a scrolling view is connected THE app bar SHALL take scrolled from it: true while any content lies under the bar (for a large bar, only once it has fully collapsed) and false when the content is at its top. WHILE size is large and scrolled is true THE app bar SHALL be drawn fully collapsed; WHERE nothing is connected and scrolled is false a large app bar SHALL stay expanded.
- **APB-10** THE app bar SHALL extend its container under the top system inset (status bar, camera cutout, window caption) and SHALL place its content below that inset and inside the left and right insets, so that its color reaches the screen's top edge while no content sits under a system bar or cutout.
- **APB-11** THE app bar SHALL NOT restyle its navigation and action items: each SHALL render as the icon-only Button it is, from its own tokens, and keep its own semantics, and the app bar SHALL NOT be focusable itself, so that focus moves from the navigation item to the action items in reading order. A glyph of an item that the system's icon set marks as mirrored (a navigation item's arrow_back) SHALL be mirrored in a right-to-left layout by the item's own icon helper (BTN-19): that is the icon's own behaviour, not a restyling by the app bar.
- **APB-12** THE app bar SHALL expose title as a heading and subtitle as text read after it, and SHALL expose the title only once WHILE size is large: the expanded title while any of it is visible, the top row's title once the bar has collapsed.
- **APB-13** WHILE title is empty THE app bar SHALL draw no title block (an expanded large bar then has an empty title row) and SHALL expose no heading; IF the app bar holds more than three action items or more than one navigation item THE app bar SHALL still render them, and in debug builds report that the extra actions belong in an overflow menu or that a bar has one navigation button.
- **APB-14** WHEN size names a value that the active design system does not have THE app bar SHALL render with the default value's tokens, and in debug builds report the unknown value.
- **APB-15** WHILE the text size is enlarged THE app bar SHALL grow its rows to hold the title and subtitle lines (its heights are minimums), SHALL keep each on one line truncated at the end, and SHALL NOT scale the navigation and action items it holds.
- **APB-16** THE app bar SHALL take its layout direction from where it is used — the direction of the nearest ancestor that sets one, and the platform's own where none does — and SHALL have no direction setting of its own; WHEN that direction changes while the app bar is shown THE app bar SHALL draw itself for the new direction at once, without a motion of its own, keeping its size and the scroll state it follows.
- **APB-17** THE app bar SHALL lay out its title and its subtitle as text of its own direction, isolated from the text around it: each takes the base direction of its first strong character, or the layout direction where it has none, so that a left-to-right word, a number with its sign or unit, a telephone number and an address keep their own order in a right-to-left layout and reorder nothing beside them; THE app bar SHALL still place each and align its lines by the layout direction, not by the text's own, and SHALL cut a title or a subtitle that does not fit (APB-5, APB-7) at the end of its own direction, so that a left-to-right label in a right-to-left layout loses its right end and never its beginning.
- **APB-18** THE app bar SHALL draw its bottom edge (APB-1, APB-2) as its dividerVisibility names: WHILE dividerVisibility is auto, as comp.app-bar.outline.visibility names, which follows sys.behavior.divider-visibility unless a design system sets the app bar apart; WHILE dividerVisibility is always or scroll, as that value names, whatever the tokens say. WHEN dividerVisibility, or the token it follows, changes THE app bar SHALL draw the other form at once, moving no part and keeping the scroll position and focus; WHEN dividerVisibility names a value the app bar does not define THE app bar SHALL treat it as auto and in debug builds report the unknown value. A platform that cannot draw a choice SHALL fall back to the first value, always, and state that in its README.
- **APB-19** WHERE the product gives the app bar a brand (an image in brand, brand-symbol or brand-wordmark) THE app bar SHALL place it at the start of the top row, after the navigation item at comp.app-bar.actions.gap from it, or comp.app-bar.container.padding-inline from the start edge WHILE no navigation item is drawn, with the title block after it at comp.app-bar.container.gap; the brand SHALL take a cell at least the minimum target high and wide, padded at its start and end by half the minimum target less comp.app-bar.brand.symbol.size, its images centred on the row's centre line, so that a symbol stands where an icon button's icon stands and a wordmark keeps that inset from the cell's edges. Right-to-left layouts SHALL mirror the order.
- **APB-20** THE app bar SHALL draw the image in brand comp.app-bar.brand.image.height high, the symbol comp.app-bar.brand.symbol.size high and the wordmark comp.app-bar.brand.wordmark.height high, each as wide as its own proportions make it, and SHALL give the brand comp.app-bar.brand.color, which an image drawn in the current color takes and an image of its own colors keeps. WHILE the brand is a lockup THE app bar SHALL draw the symbol and then the wordmark in one row, comp.app-bar.brand.lockup.gap apart, aligned in the row's block axis as comp.app-bar.brand.lockup.alignment names (center, start or end).
- **APB-21** WHERE the brand holds one image, a symbol alone or a wordmark alone THE app bar SHALL show it at every window width. WHERE it holds both a symbol and a wordmark, WHILE brandLayout is lockup THE app bar SHALL show both as a lockup (APB-20), and WHILE brandLayout is responsive THE app bar SHALL show the wordmark WHILE the window class is medium or expanded and the symbol WHILE it is compact, never both: the window class of the app shell the bar stands in, or, outside one, the window's width against sys.breakpoint.medium. WHEN the window class changes THE app bar SHALL show the other at once, without a motion of its own, the brand's link and its focus unchanged.
- **APB-22** THE app bar SHALL expose the brand as one link to brandHref named by brandLabel — or, WHILE brandHref is empty, as one image named by brandLabel — between the navigation item and the action items in reading order, and SHALL hide the brand's images from assistive technology, so that the product's name is read once whichever arrangement is shown. THE brand's link SHALL be a stop in the tab order and SHALL draw the focus indicator around its cell, in the shape comp.app-bar.brand.shape.
- **APB-23** IF brandLayout names a value the app bar does not define THE app bar SHALL treat it as lockup and in debug builds report it; IF brand holds an image together with brand-symbol or brand-wordmark THE app bar SHALL show the image in brand alone and in debug builds report the others; IF the bar has a brand and brandLabel is empty, or a brand slot holds more than one image, THE app bar SHALL still draw what it holds and in debug builds report what is missing or extra.
- **APB-24** WHILE the brand is a lockup THE app bar SHALL draw the symbol comp.app-bar.brand.lockup.symbol.size high, which is the wordmark's height (comp.app-bar.brand.wordmark.height) unless a design system sets it apart, so that the symbol and the wordmark stand at one height; a symbol shown alone keeps comp.app-bar.brand.symbol.size.
- **APB-25** WHERE the product puts a control in context THE app bar SHALL place it at the start of the top row after the brand, at comp.app-bar.actions.gap from the brand's cell (from the navigation item WHILE no brand is drawn, and comp.app-bar.container.padding-inline from the start edge WHILE neither is), vertically centred in the row, with the title block after it at comp.app-bar.container.gap; SHALL NOT restyle it, as APB-11 says of the navigation and action items; and SHALL keep it in reading order after the brand and before the title block and the action items. Right-to-left layouts SHALL mirror the order.

### Tokens

As written in the token documents (DTCG): an alias in braces names the token it takes its value from, which each design system resolves. On the web each token is a CSS custom property: `--tanrei` and the name after `tanrei`, joined by hyphens (`tanrei.comp.app-bar.…` → `--tanrei-comp-app-bar-…`).

#### `tanrei.comp.app-bar`

| Token | Type | Value |
| --- | --- | --- |
| `comp.app-bar.container.height.small` | `dimension` | `64px` |
| `comp.app-bar.container.height.large` | `dimension` | `120px` |
| `comp.app-bar.container.padding-inline` | `dimension` | `{tanrei.sys.space.space50}` |
| `comp.app-bar.container.padding-block-end` | `dimension` | `{tanrei.sys.space.space150}` |
| `comp.app-bar.container.gap` | `dimension` | `{tanrei.sys.space.space100}` |
| `comp.app-bar.actions.gap` | `dimension` | `{tanrei.sys.target.spacing}` |
| `comp.app-bar.brand.symbol.size` | `dimension` | `{tanrei.sys.icon.medium}` |
| `comp.app-bar.brand.wordmark.height` | `dimension` | `40px` |
| `comp.app-bar.brand.image.height` | `dimension` | `{tanrei.comp.app-bar.brand.wordmark.height}` |
| `comp.app-bar.brand.lockup.symbol.size` | `dimension` | `{tanrei.comp.app-bar.brand.wordmark.height}` |
| `comp.app-bar.brand.lockup.gap` | `dimension` | `{tanrei.sys.space.space100}` |
| `comp.app-bar.brand.lockup.alignment` | `number` | `0` |
| `comp.app-bar.brand.shape` | `dimension` | `{tanrei.sys.shape.corner.full}` |
| `comp.app-bar.brand.color` | `color` | `{tanrei.sys.color.on-surface}` |
| `comp.app-bar.title.typescale.small` | `typography` | `{tanrei.sys.typescale.heading-4}` |
| `comp.app-bar.title.typescale.large` | `typography` | `{tanrei.sys.typescale.heading-1}` |
| `comp.app-bar.title.inset` | `dimension` | `{tanrei.sys.space.space200}` |
| `comp.app-bar.title.color` | `color` | `{tanrei.sys.color.on-surface}` |
| `comp.app-bar.subtitle.typescale.small` | `typography` | `{tanrei.sys.typescale.body-small}` |
| `comp.app-bar.subtitle.typescale.large` | `typography` | `{tanrei.sys.typescale.body}` |
| `comp.app-bar.subtitle.color` | `color` | `{tanrei.sys.color.on-surface-variant}` |
| `comp.app-bar.outline.width` | `dimension` | `{tanrei.sys.border.hairline}` |
| `comp.app-bar.outline.visibility` | `number` | `{tanrei.sys.behavior.divider-visibility}` |
| `comp.app-bar.flat.container.color` | `color` | `{tanrei.sys.color.surface}` |
| `comp.app-bar.flat.container.elevation` | `shadow` | `{tanrei.sys.elevation.level0}` |
| `comp.app-bar.flat.outline.color` | `color` | `{"colorSpace":"srgb","components":[0,0,0],"alpha":0,"hex":"#000000"}` |
| `comp.app-bar.scrolled.container.color` | `color` | `{tanrei.sys.color.surface}` |
| `comp.app-bar.scrolled.container.elevation` | `shadow` | `{tanrei.sys.elevation.level0}` |
| `comp.app-bar.scrolled.outline.color` | `color` | `{tanrei.sys.color.outline-variant}` |
| `comp.app-bar.motion.condition-duration` | `duration` | `{tanrei.sys.motion.role.state.large}` |
| `comp.app-bar.motion.condition-easing` | `cubicBezier` | `{tanrei.sys.motion.easing.state}` |

Per-state tokens, valued by the design system's pattern rules (not written by hand):

- `comp.app-bar.<variant>.<state>.<property>` for the variants `flat`, `scrolled`, the states  and the properties `container.color`, `container.elevation`, `outline.color`.

## Guidelines

### When to use

Use an app bar at the top of a screen to say where the person is, to take them back or open the menu, and to reach the few actions that belong to the whole screen: search, share, the account. One screen has one app bar.

Use a navigation bar or drawer to move between the top-level destinations of an app. The app bar’s menu button can open a [modal navigation drawer](https://tanrei.chnotchy.com/components/navigation-drawer/). Tanrei has no bottom app bar: actions at the bottom of a compact screen are the [toolbar](https://tanrei.chnotchy.com/components/toolbar/)’s job.

### Choosing a size and a title position

| Choice | What it does | Use for |
| --- | --- | --- |
| Small (default) | One row: the navigation button, the title and the actions. | Most screens, and every screen a person reaches from another one. |
| Large | A larger title in a row of its own under the top row. As the content scrolls, it collapses into the top row, one for one with the scroll, and the top row’s title fades in. It expands again when the content returns to its top. | The top-level screens of a product, where the title is also the screen’s landmark. |
| `titleAlignment` start (default) | The title follows the navigation button. | Most bars. |
| `titleAlignment` center | The title is centered on the whole bar and narrowed equally on both sides, so it never runs under the buttons. | Products that follow iOS’s centered titles. An expanded large title always starts at the start. |

A design system may add sizes. On SwiftUI, Compose, Flutter and React Native, a size taller than small gets a title row of its own, as large does. On the web, every size the design system adds gets one. The `m3-like` preset does not have Material’s medium size yet.

### Scrolling

- **The bottom edge stands.** In the default system the bar draws a hairline bottom edge at rest and while content is under it. The bar is the edge of a region that stays in place, as an app shell’s header is, so its line does not come and go with the scroll.
- **Or it follows the scroll.** Set `dividerVisibility` to `scroll` (`divider-visibility` on the web) to draw the edge only once content lies under the bar, so that the line itself says the content has moved. A design system makes that choice for every bar, sheet and dialog with `sys.behavior.divider-visibility`, or for bars alone with `comp.app-bar.outline.visibility`. `cupertino` and `m3-like` choose `scroll`, as their originals do.
- **Flat and scrolled.** While the content is at its top, the bar lies flat. Once content lies under it, the bar takes its scrolled look: in `m3-like` a tinted container, in `cupertino` a filled one. The change eases over the bar’s condition motion and happens at once where motion is reduced.
- **A large bar is scrolled only once it has fully collapsed.** Until then, the collapse itself shows that the content has moved.
- **The bar stays put.** It does not hide while the content scrolls down. That is a screen’s scroll behaviour, not the bar’s.

### Items

- Give the bar one navigation button at most: back on a screen a person reached from another, menu on a top-level screen, close on a full-screen task.
- Keep to three actions. Put the rest in an overflow menu: a “More options” icon button that opens a Tanrei menu. Debug builds report a fourth action and a second navigation button, and still draw them.
- The buttons look as they do anywhere else. The bar lays them out and never recolors them.
- A button that goes somewhere is a link: give it an `href`. A back button that leads to the parent page, or a help action that opens a page, is then a real link, drawn as the same button. A back button that returns through history stays a button.
- On the web, a [search](https://tanrei.chnotchy.com/components/search/) can be one of the actions as its icon trigger (`bar` set to `icon`): an icon-only button that opens the search’s dialog or full-screen view.

### Brand

A product can put its logo in the bar as the link to its first page. Give the bar the address (`brandHref`, `brand-href` on the web) and the product’s name (`brandLabel`): the bar draws one link, named by that name, around the images you give it, and hides the images from assistive technology, so the name is read once. It places the brand after the navigation button, in a cell as large as an icon button’s, and gives it `comp.app-bar.brand.color` unless your images have colors of their own.

- **One image.** A single logo file in `slot="brand"`, drawn `comp.app-bar.brand.image.height` high.
- **The symbol alone.** In `slot="brand-symbol"`, drawn `comp.app-bar.brand.symbol.size` high, where an icon button’s icon stands.
- **The wordmark alone.** In `slot="brand-wordmark"`, drawn `comp.app-bar.brand.wordmark.height` high.
- **The symbol and the wordmark together.** Give both. By default (`brandLayout` `lockup`) they stand side by side, `comp.app-bar.brand.lockup.gap` apart, aligned as `comp.app-bar.brand.lockup.alignment` says. The symbol then stands at the wordmark’s height (`comp.app-bar.brand.lockup.symbol.size`).
- **By the window’s width.** Give both and set `brandLayout` to `responsive` (`brand-layout` on the web): the bar shows the wordmark while the window is medium or wider and the symbol while it is compact, never both. Inside an app shell it follows the shell’s window class.

The bar sizes each image and keeps its proportions. Don’t use the title for the product’s name: the title is the screen’s name. SwiftUI, Compose, Flutter and React Native do not draw the brand yet.

This site’s header shows Tanrei’s wordmark alone, at every width.

### Do

- Write the title as the screen’s name, such as “Inbox” or “Quarterly report”. Keep it short: it stays on one line and is cut with an ellipsis.
- Use the subtitle for context that helps someone tell two similar screens apart: an account, a status, when it was edited.
- Choose one size for all top-level screens and one for everything else.

### Don’t

- Don’t put anything but icon buttons in the slots: no text field, no search field, no tabs, no segmented control. A row of tabs goes below the bar, and a [tab view](https://tanrei.chnotchy.com/components/tab-view/) further down can be linked to it.
- Don’t put a primary action such as “Create” in the bar. Use a main action button.
- Don’t give the title a second line or make it a link.

## Accessibility

### What the component handles

- The bar has no role of its own and is never focusable. Tab goes from the navigation button through the actions, in reading order. Each button keeps its own role, name and minimum target, and a button with an `href` is exposed as a link.
- The title is exposed as the screen’s heading, a level 1 heading on the web unless `heading-level` says otherwise. The subtitle is read after it. An empty title draws nothing and exposes no heading.
- A large bar draws two titles while it collapses, but exposes only one: the expanded title while any of it is visible, the top row’s once the bar has collapsed. The heading is never read twice.
- The bar is not a landmark. Only you know whether it is the page’s banner.
- Larger text grows the bar’s rows, which are minimum heights. The title and subtitle stay on one line and are truncated rather than wrapped, even at 200%, so the bar never pushes the content away. The full title stays available to assistive technology. The icon buttons are not scaled.
- The bar takes its direction from where it is used. In a right-to-left layout it mirrors: the navigation button is at the right and the actions are at the left. A glyph that the icon set marks as mirrored, such as a back arrow, is mirrored by its own button, not by the bar.
- The title and the subtitle are laid out as text of their own direction, isolated from the text around them. A left-to-right name in a right-to-left layout keeps its order, and is cut at its own end, never at its beginning.
- Debug builds report more than three actions, more than one navigation button, and a size the design system does not have.

Known platform gaps:

- SwiftUI, Compose, Flutter and React Native: `dividerVisibility` waits for those platforms to catch up. They draw the bottom edge only once content lies under the bar, as `scroll` does.
- SwiftUI, Compose, Flutter and React Native: the right-to-left rules (a glyph mirrored where the icon set marks it, a title laid out in its own direction) wait for those platforms to catch up, as does a button with an `href`.
- Web: every size follows `rem`, so a larger browser text size grows the icon buttons with the text. Enlarging Tanrei’s type alone does not.
- Web: a large bar that follows the document’s own scroll and sits `position: sticky` in the document’s flow moves the content by its collapse as well as by the scroll, so the content moves twice as fast while the bar collapses. Put a bar that collapses above a scrolling element (`scroll-target`) or in a fixed header.
- Web: each icon button sits in a cell of the minimum target of the pointer in use, 44 for a mouse, so two actions are 52 apart from center to center where Compose and Flutter, measuring the 48 touch target, draw them 56 apart.
- SwiftUI: the bar follows its scroll view only from iOS 18 and macOS 15. Before that nothing is connected, and the bar draws what `isScrolled` says.
- SwiftUI: `isScrolled: true` on a connected large bar draws it collapsed but keeps its expanded height in the layout, so a gap shows above the content while it is at its top.
- SwiftUI: with the system bar hidden, the bar keeps the edge swipe back working by replacing the navigation controller’s pop-gesture delegate. This has been checked on iOS 26.5 only. That a large bar exposes one title at a time has not yet been checked with VoiceOver.
- Flutter: a large `TanreiAppBar` does not collapse with its content, because a box widget cannot shrink with the scroll view below it. It ignores the scroll notifications, stays expanded and is drawn collapsed only while `scrolledUnder` is true. Use `TanreiSliverAppBar` for a bar that collapses.
- Flutter: `preferredSize` reports an unbounded height, because it cannot read the theme. A scaffold lets the bar size itself, but your own layout cannot use the number.
- Compose: the collapse stops wherever the scroll stops, so a large bar can rest half collapsed. It does not snap open or shut after a fling as Material’s does. The spec does not ask for a snap.
- React Native: for a large bar to collapse one for one with its content, you lay the bar over the scroll view and pad the content by the bar’s height, as the example above does. The collapse, the insets and what screen readers announce have not yet been checked on a device.
- Every platform: under `cupertino`, the 44-point top row grows to 48 when it holds icon buttons, because their touch target is 48 and the minimum target wins. On the web it grows only under a coarse pointer, since a mouse’s target is 44. A large bar’s expanded row is also 1 point taller than cupertino’s container height.
- Every platform: with a subtitle, the expanded title is covered before the bar has fully collapsed. The spec does not yet say which title is exposed in between. Every platform moves the heading to the top row as soon as the expanded title is covered.

### What you need to do

- Give every bar a title. It is the screen’s heading.
- Give every icon button a label, such as “Back” or “Search”.
- On the web, set `heading-level` when the page already has a level 1 heading, and wrap the bar in a `<header>` when it is the page’s banner.
- Connect the screen’s scrolling view, or set `scrolled` yourself, so the bar shows when content is under it.
- On React Native, pass the window’s insets to `TanreiProvider`. On React Native and Compose, a bar that is not at the top of the window takes different insets (`insets={{ top: 0 }}`, or your own `windowInsets`).
- Keep titles short. A title cut with an ellipsis is still read in full, but sighted people see only its start.
