Tab bar

The four destinations of the product, and the other component that changes form rather than size at a breakpoint. Below a 760px container it is a bar fixed across the foot of the screen with the mark over the label; past it, the same five anchors rotate into a left rail with the mark beside the label. The census called it the one global that came out clean: exactly four links on all 28 pages and no drift on any property. It is five since 2026-08-18, on the same 28 pages and with the same absence of drift.

Organism28 grey pages112 anchors2 axes1 state, both themeshosts 1 atom

Anatomy

One row, four anchors, and inside each one an atom and a word. Everything below is drawn by design/system/components/tab-bar.css and by the icon's own file, through the same entry point a product screen loads, so this page cannot drift from the screens: there is no second copy of the CSS here to drift from.

If the box above looks empty, the component is at the foot of your window and that is correct. Under a 760px container the bar is position: fixed, so it leaves the flow of this page exactly as it leaves the flow of a screen: it sits on the glass, over the content, and does not scroll away. Widen the window past 760 and it drops back into the box as a rail. The stand has no class for holding a fixed specimen inside a stage, and that is a gap in _page.css rather than something this page should work around; it is in the report for this round.

The stage is bleed, and this component earns it. The tab bar declares no width at all in either form: at the mobile width left: 0; right: 0 hands it the whole viewport, and past 760 the shell's grid column hands it 220px. Neither number is in this file. --kit-measure would be a third width the product never gives it, so the bound is off.

Behaviour at width

From --bp-tablet, 47.5rem, and it is a @container query rather than a @media one even though this is the shell. The bar reads the box the shell is given, so a screen shown in a narrow frame gets the narrow form whatever the window is doing. The threshold is the register's own: see Width.

What changes. position goes from fixed to static and the element moves into the shell's first grid track, row 2. The flow turns from a row into a column, each anchor turns from a centred stack into a left-aligned row of mark and label, the seam moves from the top edge to the right edge, the selected marker moves from a 2px line over the tab to a 3px line beside it and gains a --bg-surface fill, and the row height drops from --row 56px to --tap-rail 48px, which is under a list row and still clear of the 44px floor.

What does not change, and it is most of the component. The destinations, their order, their labels, the mark at 22px, the label at --type-meta, and the three ways the current one is marked. This stage decides the form only: the navigation model belongs to stage 03a. It was reopened once, by the founder, on 2026-08-18, and in the right order: ia/docs/sitemap.md changed first and the markup second. Trends took second place - Home and Trends are one cluster, the same money now and over time, and the three after them are where a person acts. Measured at the narrow end, where a fifth item was the only real risk: 64px an item at a 320px viewport, no wrap, no bar overflow, no page overflow; 78px at 390; and on the rail an item is 219px whatever the count.

The bar declares no width in either form, which is why it survives a shell that changes its mind: left: 0; right: 0 hands it the viewport below the point, and the shell's 220px grid track hands it the rail above it. Neither number is in this file.

One element, two forms, and that is the accessibility argument. The usual desktop defect here is a header menu and a bottom bar both alive, with the hidden one still in the accessibility tree. It cannot happen here: there is no second carrier to hide. Measured at 28 widths from 320 to 1600 on design/home.html, exactly one visible top-level carrier at every one. The open item is the focus order: in the rail form the four destinations are tabbed last, after the content pane, while sitting visually beside the rail's head. It is written up on Width and carried in docs/backlog.md for stage 12, because the fix is markup rather than CSS.

Variants and sizes

Two axes. One is chosen by the container and one by where the person is, and neither is a size: the label is --type-meta and the mark is 22px in both forms. What changes past 760 is the direction the bar runs in and which edge carries the seam.

Form

Chosen by the CONTAINER and never by the screen. No page opts in and no page opts out: all 28 behave identically, which is why this is a container query and not a class

What changes Where it stands
Bottom bar .app > .tabbar position: fixed
row, flex: 1
--bg-surface
border-top
--row floor
mark over label
Under a 760px container, on all 28 pages. Bottom chrome: it sits on the glass and never scrolls away, which is why every screen's foot padding is calc(var(--row) + var(--space-48))
Left rail @container 760 position: static
column, flex: none
--bg-recessed
border-right
48px floor
mark beside label
At or past 760, on the same 28 pages. It takes row 2 of the shell's grid, under the app bar in row 1, and the current tab gains a paper fill: the one place in the product where a paper fill marks a selection rather than a card
A collapsed or icon-only form - Forbidden with a reason rather than missing. Four labelled destinations is the whole navigation of this product, and a mark with no word is a puzzle. This is the audience that avoids finance apps: the one component they must be able to read at a glance is the one that says where they can go. There is no fifth destination either, and DESIGN.md says so

There is no live specimen row in this matrix, and that is deliberate rather than an omission. The two forms are mutually exclusive at any one moment: the query reads the body container, so a second specimen would render in exactly the same form as the first one, and a stand cannot fake a second container without re-pointing every query on the page at a different box. The live component is in Anatomy, in whichever form your window currently gives it. To see the other one, drag the window across 760. The measured numbers on both sides of that line are in the technical half.

Destination state

Chosen by where the person IS, never by importance. One of the four is current and the other three are not; a bar with two current tabs is a bug, not a variant

Tokens Where it stands
Resting .tabbar a --text-muted
weight 400
transparent line
84 of the 112 anchors. Three per page, on all 28
Current .tabbar a.cur --text-action
weight 600
--line-selected
28 anchors, exactly one per page. Petrol spent on the current selection, which is one of its four permitted jobs in this product
Carrying the dot .ic .dot --text-body
owned by destination-icon.css
Alerts, and nowhere else. It combines with either of the two above and is not a third state of the anchor: it is a property of the icon, and the icon is an atom with its own file

Both axes are declared, and the census found no third. 72 tab bar controls were measured across the coloured sample at two viewports and returned two values on exactly three properties, weight, border colour and ink, all three of them .cur. Nothing else in this component varies anywhere it stands.

When to use it

There is one tab bar and it is the whole navigation of the product. A person meets it the moment the guided reveal ends and the calm view begins, and from then on it is on every screen of the app that is not a step in a chain: the onboarding pages have no tab bar at all, because a person in the middle of connecting a bank has one thing to do and four ways out is not kindness.

Four destinations, and the number is a decision. Home is the calm view J-MAIN asks for, Alerts is the "never be surprised" job, Save is the cancel job, You is the account. Every job the product claims has a door in this bar, and nothing else does. A fifth would mean a fifth job, and that is a product decision rather than a navigation one.

The four labels come from voice/docs/microcopy.md and are one word each: Home, Alerts, Save, You. They are nouns and not verbs, because a destination is a place rather than an action, and "You" rather than "Account" or "Settings" because this product speaks in the second person everywhere else too.

The dot is the quietest alarm the product owns. It sits on Alerts when something has happened and it never carries a number: a count invites arithmetic, and the person this product was designed for is already anxious about the numbers. DESIGN.md: "A quiet dot may sit on Alerts; there is never a count."

The rule, and the anti-rule

A destination goes in the bar

Four places a person can be, one of them marked. Under 760 this specimen pins itself to the foot of the window on top of the one in Anatomy, which is why this box looks empty at a phone width. It is deliberately the identical bar rather than a second arrangement: two live specimens that superimpose exactly read as one correct bar, and two that differ would show a reader a current destination the paragraph beside it never mentioned. That is also why there are two live tab bars on this page and not five.

Use a nav row instead

A place inside a destination is not a destination. "Your sources" and "Data and privacy" live behind You, and they are Nav rows on the Settings screen: a line with a sentence saying what is behind it, and a chevron. They never join the bar. The bar answers "where can I go", four times, for the whole product; a nav row answers "what is in here", inside one of the four. Adding a fifth tab to reach one of them would put a leaf of the tree next to its own branches.

Limits

U2, context. No tab bar inside .app.flow, and none on the landing. The chain is one task and the way out of it is finishing it. The counter is unusually clean here: 26 grey pages are a flow and not one of them carries this component, 28 carry it and not one of them is a flow. U18, context. The five destinations this bar names are the five screens that may not carry a back control: the way out of a destination is this bar, and a back control there offers to leave a place the person did not arrive at from anywhere in particular. History and trends carried one until 2026-08-19, from the day before it became a tab.
Full table with sources and how to check: Usage rules.

U13, context. LIFT and PRESS. The destination icon rises by one nudge when its tab is hovered, the same verb the merchant mark takes in a row and for the same reason: the icon is what the destination IS. The current tab does neither - it is where you already are, so answering a pointer on it would offer a journey that does not exist. Everything in this product that moves travels exactly one --nudge, which is 2px, and nothing anywhere travels two. Hover and release run at --dur-state (150ms) on --ease-arrive; a press runs at --dur-press (90ms), because it answers a finger that is already down. The full language and the live specimens are on Motion; the table with sources is Usage rules.

States

One state, and the component is interactive: the four anchors are real links with real destinations. The missing states are decisions rather than omissions, and each one is named below.

There is no hover, and it is not an oversight. kit.css declared no .tabbar a:hover and never had, so adding one here would change how 18 coloured screens render with none of this stage's three named sources behind it. It is also defensible on its own terms: this is the bottom chrome of a phone-first product, where a hover does not exist at all; the current destination is already marked three ways at once; and a fourth response on the other three would put movement into the calmest object on the screen. It stays as the product wrote it, and whether it should change is the founder's call rather than a side effect of moving a file.

There is no active and no disabled. A destination that cannot be reached is not rendered dim, it is not rendered, and all 28 pages carry all four.

Rest, live

The bar is live and real in Anatomy, at the top of this page: tab into it and read the ring off the real thing. Faking a state with .is-hover is the same defect as inventing a role, because it documents something the product cannot do.

The state, and the selection, in both themes

The states are described, not photographed. Founder's decision, 2026-08-11: the snapshot strip went from every page in this kit, 120 images across 18 pages. A value moves, and every picture of it is quietly stale until somebody re-shoots it. A stale picture is worse than no picture, because it looks checked. What is left is what a picture never carried anyway: the state, and the token it reads.

Restlight
--text-muted on the label and, through currentColor, on the mask as well. On --bg-surface in the bar and --bg-recessed in the rail
Restdark
the same roles, the other half of each pair. The rail's ground stays the recessed step in both themes: in the light theme it is darker than the surface and in the dark theme it is lighter, and it is a recess either way
Currentlight
--text-action at 6.2:1, weight 600, and --line-selected. Two tokens on one value, because ink answers to 4.5:1 and a line answers to 3:1
Currentdark
the same two roles on the lightened petrol, 6.8:1. In the rail it also takes --bg-surface as a fill, which in the dark theme is the paper that is darker than the panel it sits on
Focus visiblelight
--color-focus, 6.23:1 as a line, at 2px offset so the ring sits outside the anchor and moves no neighbour
Focus visibledark
--color-focus, 6.82:1. These are the last four stops in the tab order of every screen and the only way a keyboard user changes destination, so this is the one state on this component that has to survive both themes intact

The ring is declared in the component's file as well as in base.css, and it moves no pixel: same token, same 2px, same 2px offset. It is re-declared at the component's own specificity so that nothing added to this file later can out-rank the base ring and leave a keyboard user with no mark at all.

The technical half

ReadsWhich tokenGrowing from
the bar's ground--bg-surface--paper, and --paper-dark in the dark theme
the rail's ground, past 760--bg-recessed--panel. The desktop rail is one of the two things this role was named for
the seam: top edge, then right edge--line-container--hairline. A container edge and not a divider: the bar is a surface over the screen, not a separator inside one
a resting destination--text-muted--muted, the single secondary tier. The mask takes it too, through currentColor
the current destination--text-action--petrol. ink, 4.5:1, measured 6.2:1 light and 6.8:1 dark
its 2px line, and its 3px line in the rail--line-selected--petrol. line, 3:1. The same value as the ink above and a different role, because one is text and one is a boundary
the current destination's fill in the rail--bg-surface--paper
the focus ring--color-focus--petrol
the anchor's floor in the bar--rowprimitive, straight. 56px, twelve over the 44px of --tap, which this file never reads
the label--type-metaprimitive, straight: geometry has nothing to override
padding and gap--space-0, --space-4, --space-8, --space-24primitive, straight
the anchor's floor in the railnonemin-height: 48px, a literal, and the one hole in this file. It is neither --tap (44) nor --row (56), the only two size primitives the system has. --tap-rail was proposed at stage 07 and deferred to stage 08, which did not create it. Not folded into --space-48, which is a spacing and a different scale
the stacking ordernone--z-tabbar, a primitive since 2026-08-20. It sits one over the app bar's --z-appbar, and the pair was declared on the founder's decision for a reason that is not tidiness: 5 and 6 are not two values, they are one relationship - where the two chromes meet, the bottom one wins - and it was carried in prose in two files. Proposed at stage 07, deferred to 08, refused by 09 as "three literals are not a scale", which was right about the money field's 1 and wrong about these two
the icon, the dot, the four masksnone, and not hereAll of it is destination-icon.css. This file gives the atom nothing, not even a size, and it is not scoped back to .app .tabbar a .ic

Two folds moved pixels, both from the founder's review of the foundations. The anchor's padding was 9px 4px 10px and folds to --space-8 --space-4, because 9 and 10 land on the same step, so the three-value form honestly becomes two. The rail's side padding was 20px and folds up to --space-24, so each destination sits 4px further in from the rail's edge. Nobody chose either: the mapping runs to the nearest step with ties to the larger and is applied mechanically to every value in the system.

DESIGN.md disagrees with this component in two places and the code wins in both, exactly as it did on the subscription row. The document says the label is 11px and the current tab is weight 700; the stylesheet has shipped 12px and 600 on 112 anchors since stage 07. It matters more than a footnote: 11px folds to --type-label and 12px folds to --type-meta, so believing the document instead of the code would have shrunk every destination label in the product by two pixels. Both rows are in docs/tokens-audit.md.

The container is body, and it is declared in base.css, not here. A container query matches an ANCESTOR container and never the element itself, so a container-type on the bar, or on .app above it, would not let this component query its own width; it would re-point the query at a different box. That is measured rather than argued: with container-type on .app the bar kept its column flow, lost the rail, and moved from 220px beside the content to 965px below it. 760 is written as a number because a custom property is not valid inside a @container condition.

The shell positions this component and the rule is written here, in the child's file. position: fixed, the two grid coordinates and the 220px column are all the App shell placing its child. Three of the four are in this file, on the same convention that leaves .appbar .plan in chip.css; the fourth, the column width, is .app's own grid-template-columns and is not repeated here. One of the two files has to say which, and this one does.

Lives in design/system/components/tab-bar.css. Stands on 29 grey pages and 29 coloured pages, 116 anchors on each side, exactly FOUR on every one, and all 29 carrying aria-current. Recounted 2026-08-20: the bar took a fifth destination on 2026-08-18, when Trends became a tab, and the sentence here still said four. Coloured screens carrying it: Home, Home, save focus, Subscription detail, History and trends, Settings, Settings, no account, Alerts and its three states.

<nav class="tabbar" aria-label="Global navigation"> <a class="cur" href="home.html" aria-current="page"><span class="ic ic-home"></span>Home</a> <a href="alerts.html"><span class="ic ic-alerts"><span class="dot" role="img" aria-label="new alerts"></span></span>Alerts</a> <a href="settings.html"><span class="ic ic-you"></span>You</a> </nav>

The nav and its aria-label are part of the copy for a reason: this is the global navigation landmark of every screen, and a screen reader needs to be able to jump to it. So is aria-current="page": .cur paints the current destination and that attribute says it, and the census confirmed both on all 28 pages.