Top Header Mobile
Mobile top app bar with leading/trailing slots, centred or start-aligned title, subtitle, six variants, five sizes, tokened bottom rounding, three layout modes, scroll elevate/collapse behavior, search and sub-row decks, Surface tone, and full RTL support.
pnpm add @tessinaui/uiWhen to use
- The top of a phone screen. A bar that names where you are, offers a way back or to a menu, and holds the few actions that belong to this screen.
- A short title. One or two words that describe the screen, not the app's
name. Set
titleAs="span"when the page H1 lives in the content. - A handful of actions, one of them prominent at most. Keep the rest
ghostand move overflow into a DropdownMenu. - A bar that follows the content. Use
stickywithscrollBehaviorso the bar lifts, or a large title folds to inline, as content scrolls under it. - A second deck under the title. Compose a Search
field into
search, or Tabs or a Segmented Control intosubRow.
When not to use
- On a desktop-width page. Use Top Header Desktop.
- For an app's top-level destinations. Use a Bottom Nav on a phone or a Sidebar on a wide screen; the bar hosts a menu trigger only.
- For a sheet or modal header. Top rounding and a grabber belong to a Drawer or an Action Sheet.
- For a full-screen takeover menu or a command palette. Use a Drawer or Command; the bar holds the trigger.
- For a screen with no title, navigation or actions. Leave the bar out rather than render an empty one.
- To stack many similar actions. More than a few icons crowd the title; use a menu.
Installation
pnpm add @tessinaui/uiUsage
import { TopHeaderMobile } from "@tessinaui/ui";
import { IconButton } from "@tessinaui/ui";<TopHeaderMobile
bordered
title="Inbox"
subtitle="3 unread"
leadingAction={<IconButton variant="ghost" size="sm" icon={<ArrowLeft />} aria-label="Back" />}
trailingActions={<IconButton variant="ghost" size="sm" icon={<Search />} aria-label="Search" />}
/>Examples
Default
Back, dead-centred title, one trailing action — the dominant single-row shape, composed from library IconButtons. The second bar names the back destination (a ghost Button in the same slot).
Variants
Six paints — filled, elevated, transparent, blur, primary, on-color.
The coloured variants publish the Surface tone, so nested library controls
re-ink themselves. blur paints opaque inside a coloured <Surface>, where
its glass would be mostly the plate and a focus ring would lose its ground.
Sizes
Bar min-heights 48 / 56 / 64 / 80 / 96px. xs, sm and md are the three
heights that actually recur in shipping mobile bars: 48px for a dense shell,
56px for a standard global nav, 64px once the row carries a filled action.
Layout modes
small keeps the title inline; medium and large drop it below the
action row. Content-hugging — a subtitle simply grows the bar, and the large
row wraps up to two lines instead of truncating.
Scroll behavior
scrollBehavior="elevate" paints a hairline + shadow once content scrolls
under the sticky bar; "collapse" additionally folds a medium/large bar to
the inline title — the large-title fold. An
IntersectionObserver sentinel drives both — no scroll listeners.
Subtitle
Presence, status, count or context under the title — in centred, start-aligned and large layouts. The chat header composes an Avatar into the title cluster.
Title alignment
center holds the title dead-centre regardless of the side clusters; start
left-aligns after the leading slot. Both slots are optional.
Title menu
The title as a dropdown trigger — the feed/workspace switcher pattern. Pure composition with DropdownMenu; the hand-rolled trigger carries its own 44px touch band.
Search
The search slot renders its own deck under the action row. The deck is a plain
padded box — the field you compose owns the search landmark, so
Search brings its own and nothing is nested. Passing
a bare <input>? Wrap it yourself in <search> or <form role="search">. For a
field that replaces the title, pass it as the title.
Sub row
The structured second deck — tabs, segmented control, or a scrolling chip rail — padded to the bar's own gutter.
Transparent over media
The bar paints nothing; the actions carry their own fill (chip-style
IconButtons), tone="on-color" re-inks any text, and the H1 stays in the
content.
Select mode
Cancel + live count + one prominent Done — pure composition, no mode prop. The three-control row needs about 320px of bar width; in a narrower column drop the count or move Cancel into a leading icon.
Commerce
Brand mark in the title slot, a filled CTA, wishlist and a cart count on IconButton's own badge.
Skip link
WCAG 2.4.1: TopHeaderMobileSkipLink hides until keyboard focus, then pins
over the bar's leading corner. Click into the demo and press Tab.
On color
A transparent bar inherits the tone around it, so over a coloured Surface its title and icon buttons take the plate's ink. primary and on-color publish on-color themselves, and blur turns opaque on a coloured plate.
Loading
The skeleton mirrors the bar's box — same cva, same size maps, icon circles on IconButton's size tokens, text bars sized in characters.
RTL
Set dir="rtl" and the row mirrors: the back control sits at the right and the
trailing actions at the left. The back icon is yours, so pass one that points
right. Leave dir unset and the row follows the page.
API Reference
TopHeaderMobile
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "filled" | "elevated" | "transparent" | "blur" | "primary" | "on-color" | "filled" | Visual style. primary/on-color publish the on-color Surface tone |
size | "xs" | "sm" | "md" | "lg" | "xl" | "md" | Bar min-height 48/56/64/80/96px |
rounded | "none" | "sm" | "md" | "lg" | "full" | "none" | Bottom-corner rounding; full = the tokened panel scale |
title | ReactNode | — | Title text or custom element |
titleAs | "h1" | "h2" | "h3" | "span" | "h1" | Heading element for a string title. Use "span" when the page H1 lives in the content |
titleAlign | "start" | "center" | "center" | Inline-title alignment |
subtitle | ReactNode | — | Second line under the title (status, presence, context) |
leadingAction | ReactNode | — | Leading slot (back/menu control) |
trailingActions | ReactNode | — | Trailing slot (icon buttons, text actions) |
layout | "small" | "medium" | "large" | "small" | Inline title, or title below the row at two scales |
collapsed | boolean | — | Fold medium/large to inline. Leave unset with scrollBehavior="collapse" for self-folding; set it and the prop wins |
sticky | boolean | false | sticky top-0 + safe-area top inset |
bordered | boolean | false | Hairline bottom border |
scrollBehavior | "none" | "elevate" | "collapse" | "none" | Scroll response — elevate chrome, or elevate + collapse. Pair with sticky |
search | ReactNode | — | Search deck row under the bar; a plain padded box, the composed field owns the search landmark |
subRow | ReactNode | — | Structured second deck (tabs, segmented, chips) |
tone | "default" | "on-color" | auto | Surface tone for nested controls; auto-detected from variant, override for transparent-over-media |
skeleton | boolean | false | Swap the bar for its skeleton |
render | useRender.RenderProp | — | Replace the rendered <header> element |
dir | "ltr" | "rtl" | — | Text and layout direction |
asChild was removed — it was declared but never implemented and leaked onto
the DOM. Use render instead.
TopHeaderMobileSkeleton
| Prop | Type | Default | Description |
|---|---|---|---|
variant / size / rounded / layout / bordered | same as the bar | — | The skeleton is the same box |
showLeading | boolean | true | Leading circle |
trailingCount | number (0–3) | 1 | Trailing circles |
titleChars | number | 12 | Title bar width, in characters of the real title |
showSubtitle | boolean | false | Subtitle line |
TopHeaderMobileSkipLink
| Prop | Type | Default | Description |
|---|---|---|---|
href | string | "#content" | Jump target. Render it first inside leadingAction |
Keyboard navigation
| Key | Action |
|---|---|
Tab / Shift+Tab | Move focus through the slot controls in DOM order (skip link first when present) |
Enter / Space | Activate the focused control |
The bar itself is not interactive and never traps focus; arrow-key behavior belongs to composed widgets (Tabs, SegmentedControl, DropdownMenu).
Accessibility
VoiceOver + Safari: passed 2026-09-14. The bar reads "Back, button", "heading level 1, Inbox", "Search, button"; the second example reads "Orders, button" and "heading level 1, Order #8104". NVDA and JAWS are untested.
- Renders a plain
<header>with no hardcodedrole="banner"— the banner landmark applies only at body level (APG); nested bars stay generic. - A string
titlerenders astitleAs(defaulth1). Set"span"for actions-only bars so the content owns the document outline. - The
searchslot is a plain padded box — no landmark of its own, so a composedSearch(which rendersrole="search") is not nested inside a second one. Wrap a bare<input>yourself if you want the landmark. TopHeaderMobileSkipLinkprovides the WCAG 2.4.1 bypass block.- Slot controls must carry
aria-labels; library IconButton enforces the 44px touch floor on phones. - The skeleton is one
role="status"region announced by sr-only text. - The large-title fold animates opacity only, gated by
motion-safe. - RTL:
dir="rtl"flips the row; the title cluster right-aligns.
Boundaries
- Trailing actions: two icons is the working cap (one if it is filled); the observed mobile mode is two, max five. Keep to ≤3 and push overflow into a DropdownMenu — never an in-bar auto-overflow.
- One prominent action. At most one filled/text-emphasized action, on the trailing side. Everything else stays ghost — two prominent actions in one bar leaves neither reading as the primary one.
- Back vs Close are different verbs. Back returns within a flow; Close dismisses a modal context. A sheet header (top rounding + grabber) belongs to Drawer/ActionSheet, not this bar.
- Full-screen takeover menus belong to Drawer/Command — the bar hosts the trigger only.
- Bottom rounding only. A top bar never curves its top edge: curvature
there implies expandability, and the edge is flush with the status bar
anyway. Our
roundedaxis applies to the bottom edge only, tokened through §2. - Non-sticky bars at the very top of a standalone PWA still sit under the
status bar — add
pt-[env(safe-area-inset-top)]viaclassNamethere, or usesticky.