Summary#
TabbedShell puts the app's primary sections across the top on a pointer, and along the bottom edge on a phone.
Reach for it when the sections are peers — a handful of them, no deep hierarchy — and when a good phone
experience matters, because a bottom bar is one thumb-reach from every section where a drawer is a tap away from
even seeing them.
It reads exactly the same declaration as every other arrangement, so switching is one word:
[Layout]
[AllowAnonymous]
component TabbedLayout() {
action Palette() { }
action Inbox() { }
render {
TabbedShell(new AppChrome {
Product = "Expensely",
Tagline = "Finance operations",
Home = "/",
User = new ShellUser { Name = "Olivia Rhye", Secondary = "Finance manager", Initials = "OR" },
Nav = [
new NavItem { Label = "Overview", To = "/", Icon = Icons.Home, Exact = true },
new NavItem { Label = "Approvals", To = "/approvals", Icon = Icons.CheckCircle, Badge = "12", BadgeTone = Tone.Warning },
new NavItem { Label = "Settings", To = "/settings", Icon = Icons.Gear, Children = [
new NavItem { Label = "People", To = "/settings/people", Icon = Icons.Users },
] },
new NavItem { Kind = NavKind.Section, Label = "Administration", Children = [
new NavItem { Label = "Audit log", To = "/audit", Icon = Icons.File },
] },
],
}) {
Outlet(retain: 8);
slot search { ShellSearch("Search requests, people or departments", onPress: Palette); }
slot actions { ShellCountButton("Notifications", 3, onPress: Inbox) { Icon(Icons.Bell, size: 18); } }
}
}
}
[Page("/")] [Layout(TabbedLayout)] [Title("Overview")] [Render(CSR)] [AllowAnonymous]
component TOverview() { render { PageHead("Overview"); Card("This month") { Text("Nothing needs you."); } } }
[Page("/approvals")] [Layout(TabbedLayout)] [Title("Approvals")] [Render(CSR)] [AllowAnonymous]
component TApprovals() { render { PageHead("Approvals"); } }
[Page("/settings")] [Layout(TabbedLayout)] [Title("Settings")] [Render(CSR)] [AllowAnonymous]
component TSettings() { render { PageHead("Settings"); } }
[Page("/settings/people")] [Layout(TabbedLayout)] [Title("People")] [Render(CSR)] [AllowAnonymous]
component TPeople() { render { PageHead("People"); } }
[Page("/audit")] [Layout(TabbedLayout)] [Title("Audit log")] [Render(CSR)] [AllowAnonymous]
component TAudit() { render { PageHead("Audit log"); } }Signature#
TabbedShell(AppChrome chrome) {
Outlet(retain: 8); // the routed page
slot search { … } // the brand row — `ShellSearch(…)` is shaped for it
slot actions { … } // the brand row, right of search
slot railFoot { … } // no rail here: it sits under the page as a help card
slot aside { … } // beside the page at 1100+, under it below that
slot primary { … } // a task's commit bar — `ShellTaskBar`. See [FocusedShell](/reference/ui/shell-focused/)
}Everything in AppChrome, NavItem, ShellUser and MenuAction is the shared contract — see App shells.
Description#
What sits where, at each width#
| band | brand row | navigation |
|---|---|---|
compact < 768 | mark · page title · actions · identity | a bottom tab bar: up to five entries, plus "More" |
pointer 768+ | brand · search · actions · identity | a horizontal tab strip on its own row, plus a secondary strip while you are inside a section that has sub-areas |
wide 1100+ | the same | the same, and the page sits beside its aside |
Two rows on a pointer, not one. A single row has to divide one line between the brand, the tabs, the search box, the page's actions and the identity chip — so the tabs, which are the point of the arrangement, get whatever is left and begin overflowing at about eight entries on a 1280 screen. Two rows give the strip the full width. It costs 52px of height on a surface that has plenty.
What happens when the tabs stop fitting?#
The strip scrolls. It never wraps to a ragged second row, and there is no "More" menu on a pointer.
A measured priority-plus menu — count what fits, move the rest into an overflow — is the richer answer, and the
platform cannot honestly build it today. It needs a per-element width, and the only measurement a component can
read is Layout.Width, its own container's. Deriving the cut from label lengths instead was considered and
rejected for a specific reason: Layout.Width is null on the first render and arrives after it, so every page load
would paint a full strip and then jump tabs into an overflow that was not there a frame earlier — in the common
case, where everything fits, not the rare one. A layout that moves under the reader on every navigation is a worse
failure than a strip they have to scroll.
Scrolling costs nothing when the tabs fit, hides nothing permanently, and keyboard focus scrolls a tab into view natively. The scrollbar itself is suppressed, because a horizontal bar under a tab row reads as a rendering fault.
How does a phone reach the sections that did not fit?#
The bottom bar holds up to five entries — a count, not a measurement, and deliberately so: iOS and Android both cap a tab bar at five by convention, so a deterministic cut is the platform behaviour rather than an approximation of it. If the tail would be a single entry the bar simply shows all of them, because "four and a More holding one" is worse than five.
Everything else lives in the "More" sheet, and the sheet holds the whole nav tree rather than only the tail. That matters: a sheet holding only the overflow would leave a barred entry's children with nowhere to live, so a phone reader could reach "Settings" and never "Settings → Billing". Showing everything is also what a "More" screen is on both native platforms.
What does a tab with children do?#
It navigates. That is all a tab ever does. A tab is a destination, and one press does one thing.
A NavItem with Children goes to its own To when it has one, and otherwise to the first route beneath it
(NavItem.FirstRoute). An entry whose whole subtree holds no route at all is drawn as plain text rather than as a
link, because a link to nowhere accepts the press and does nothing.
Its children are not hidden behind that press. Once you are inside the section, they appear in a secondary strip beneath the tab bar, carrying that section's areas and no others — the shape GitHub's repository tabs, Stripe's dashboard and the Azure portal all converge on. Anything nested deeper than the strip's own level is flattened into it rather than dropped.
⚑ Why not a dropdown? Because a tab that both navigated and opened a menu did two things on one press, which is a menu bar (File / Edit / View) rather than a tab strip — a different interaction model, and one this shell is not for. The strip also shows a section's areas without a press instead of hiding them behind one.
The strip appears only when the section you are in actually has sub-areas. A section with none costs nothing: no empty rule, no reserved height, so the page does not shift as you move between the two kinds of section.
⚠ The strip's landmark is the section's own name — "Settings sections", not a second "Main navigation". Two
landmarks with the same name make a nav unnavigable by keyboard, and would make every within: in your tests
ambiguous.
The phone gets the same strip, under the slim top bar, for the same reason and from the same declaration. The "More" sheet still holds the whole tree, so it remains the exhaustive index rather than the only way in.
⚠ A top-level Kind = NavKind.Section becomes its children. A bar has nowhere to put a heading and nothing to
do when one is pressed, so Primary() replaces the section with the entries under it. A rail has the room to draw
the heading; a strip does not. Both readings are right for their own arrangement.
Reading the current tab#
Exactly one tab is ever lit: the shell asks AppChrome.CurrentRoute for the longest matching route in the whole
tree, so /settings and /settings/people do not both light up on the child's page. A route no tab covers — a
record detail, a wizard step — leaves no tab current, and the strip names the page on its trailing edge instead of
leaving it anonymous.
⚠ Give a non-/ home Exact = true. The prefix rule claims every route under an entry and only / is exempt
from it, so an "Overview" at /t would otherwise light on every page beneath it.
Examples#
new NavItem { Label = "Settings", To = "/settings", Icon = Icons.Gear, Children = [
new NavItem { Label = "People", To = "/settings/people", Icon = Icons.Users },
] },The primary strip is the nav landmark named "Main navigation", the same name every arrangement uses; the
secondary one is named after its section:
Ui.Click("Settings"); // ONE act — the tab navigates, and nothing else happens
Assert.OnPage("/settings");
Assert.Visible("People", within: "Settings sections");
Assert.Below("Settings sections", "Main navigation"); // under the bar, above the page
Ui.Click("Invoices"); // a level deeper, flattened into the same strip
Assert.OnPage("/settings/billing/invoices");Notes#
⚠ The identity chip is in the brand row, not at the strip's end. The strip needs its full width for tabs, which is the reason the arrangement has two rows at all.
⚠ railFoot has no rail here. It is rendered under the page as a bounded help card rather than dropped — a slot
an app filled and a shell discarded is lost content, not lost layout.
⚠ The secondary strip does not stick. The tab bar pins because it is the app's orientation and true on every page; the section strip belongs to the section's content and scrolls with it. Three pinned bars would eat a third of a laptop's viewport before the page began.
⚑ A shell holds no nav state on a pointer. Which section you are in is read from the route, never from a panel the reader had to open — so what the navigation shows and where you actually are cannot disagree.
See also#
- App shells — the shared
AppChromecontract every arrangement reads - RailShell — a permanent icon rail, when the canvas is the product
- FocusedShell — one task, no navigation at all. A focused route can live inside a tabbed app:
[Layout(…)]is per-page, so a wizard is a route rather than an application - Navigation —
Navigation.Routes,[Title], and what a shell reads from them - accessibility — landmarks,
role:,current:and the naming props