Summary#
RailShell is a permanently narrow icon rail — 60px, at every pointer width, with no collapse control. Reach
for it when the canvas is the product and the navigation should take as little of it as possible, and when the app
has few enough top-level sections that a glyph each is genuinely legible.
[Layout]
[AllowAnonymous]
component RailLayout() {
action Palette() { }
action SignOut() { Session.SignOut(); }
render {
RailShell(new AppChrome {
Product = "Expensely",
Home = "/",
User = new ShellUser {
Name = "Olivia Rhye", Secondary = "Finance manager", Initials = "OR",
Menu = [ new MenuAction { Label = "Sign out", Icon = Icons.Logout, OnPress = SignOut, Tone = Tone.Danger } ],
},
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 { Kind = NavKind.Section, Label = "Administration", Children = [
new NavItem { Label = "Settings", To = "/settings", Icon = Icons.Gear, Children = [
new NavItem { Label = "People", To = "/settings/people", Icon = Icons.Users },
] },
] },
],
}) {
Outlet(retain: 8);
slot search { ShellSearch("Search", onPress: Palette); }
}
}
}
[Page("/")] [Layout(RailLayout)] [Title("Overview")] [Render(CSR)] [AllowAnonymous]
component ROverview() { render { PageHead("Overview"); Card("This month") { Text("Nothing needs you."); } } }
[Page("/approvals")] [Layout(RailLayout)] [Title("Approvals")] [Render(CSR)] [AllowAnonymous]
component RApprovals() { render { PageHead("Approvals"); } }
[Page("/settings")] [Layout(RailLayout)] [Title("Settings")] [Render(CSR)] [AllowAnonymous]
component RSettings() { render { PageHead("Settings"); } }
[Page("/settings/people")] [Layout(RailLayout)] [Title("People")] [Render(CSR)] [AllowAnonymous]
component RPeople() { render { PageHead("People"); } }Signature#
RailShell(AppChrome chrome) {
Outlet(retain: 8); // the routed page
slot search { … } // the top bar
slot actions { … } // the top bar, right of search
slot railFoot { … } // the phone DRAWER only — a 60px rail cannot hold a 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/)
}Description#
How is this not the sidebar collapsed?#
SidebarShell narrows to an icon strip as a state — the reader chooses it, the wide band un-chooses it, and
while it is narrow a row shows a glyph and nothing else. RailShell is narrow always, and is built around that
constraint rather than tolerating it. Three things differ, and all three are mechanism:
- Every row carries its label, as a flyout on hover and on keyboard focus. The sidebar's collapsed rail has none, which makes an icon-only nav a memory test — the single biggest reason narrow rails have a bad reputation.
- The identity lives at the rail's foot, not in the top bar. That is not decoration: it frees the entire top bar for page context, which is what lets this arrangement give a page a genuinely wide, uncluttered header.
- It expands into a panel that FLOATS. Press "Expand navigation" at the rail's foot and the 60px rail widens into a labelled panel over the page. The content column never reflows, because the 60px column stays exactly where it was and the panel is a layer you dismiss rather than a resize you undo.
⛔ Floating rather than pushing is what keeps these two shells two shells. SidebarShell already expands by
changing its width, with the page reflowing each time; that is its model and a good one for a shell whose nav
is the app's primary structure. A rail that did the same would be SidebarShell with a smaller collapsed width and
no reason to exist.
Expanding the rail#
The control sits at the foot of the rail, above the identity — chrome about the rail belongs with the rail's other chrome, not among the destinations (a row that is not a place) and not crowding a 60px brand tile.
It is a real button with a changing accessible name — "Expand navigation" / "Collapse navigation" — and it
carries expanded:, so assistive technology is told which state it is in and a test can drive it. An expansion only
a mouse can reach is not an expansion.
Assert.Hidden("Approvals", within: "Main navigation"); // narrow: glyphs only, the label is a hover flyout
Ui.Click("Expand navigation");
Assert.Visible("Approvals", within: "Main navigation"); // the panel labels every row in place
Ui.Click("Collapse navigation");While expanded, every row shows its label and badge in the row itself, railFoot appears (a panel has the width for
a card), and the identity chip shows the person's name rather than only their mark. Following any row collapses the
panel again — the panel covers the page, and the page is what you just asked for.
⚠ Expansion is a pointer affordance only. On a phone the drawer already shows every label, so there is nothing to expand into and the control is not drawn.
How a label appears with no JavaScript#
The tip is an ancestor-conditioned variant: a descendant's base block names an ancestor component and a
pseudo-state, and it lowers to a plain CSS descendant rule.
variants {
base { Display = Display.None; Position = Position.Absolute; Left = "100%";
inside RailNavLink.Hover { Display = Display.Flex; }
inside RailNavLink.Focus { Display = Display.Flex; } }
}⚠ The .Focus twin is not optional. A rail whose labels exist only under a pointer is unusable from the
keyboard. And :focus matches only the focusable element itself, so the ancestor named here has to be the
Link — a wrapper element as the component's root would make the keyboard half silently dead.
⚠ Display.None, never Opacity = 0. A merely transparent tip keeps its text in the layout and in
textContent, so every rail label would read as on-screen to Assert.Visible whether or not anybody could see it.
What does a group do?#
A group's children open a flyout beside the rail, pinned by a click — not by hover. A hover-only disclosure does not exist on a touch screen, so its children would be unreachable, which is the same as not shipping them. Hover and focus reveal the row's label, which is purely informational and safe to leave to the pointer.
A group carrying a To navigates as well as disclosing: a link with a thin chevron strip down its trailing
edge, named "Settings sub-pages" so the two targets in one row do not answer to the same words. Anything nested
deeper than the flyout draws is flattened into it rather than dropped.
⚑ This is the one shape a rail keeps that a tab strip does not, and the difference is the model. A rail row is a row in a list — disclosing under or beside it is what a list does, and the flyout exists because 60px has no room for the children's names. A tab is a destination, so TabbedShell refuses the same mechanism and puts a section's areas in a secondary strip instead. Expanding the rail closes any pinned flyout, because the panel shows those names in place and the same information twice is worse than once.
What changes on a phone?#
The rail becomes a labelled off-canvas drawer opened from the top bar — a drawer has the width for words, so
the rows are labelled there rather than icon-only, and railFoot appears because a drawer can hold a card.
⚑ This is close to the sidebar's compact band on purpose. On a phone a left-nav app is a drawer; inventing a difference in order to look different would be a worse design. What the arrangement keeps is its own identity placement — the person is at the drawer's foot, not in the top bar.
Examples#
// The rail is narrow — the claim that distinguishes the arrangement, and the only one geometry can check.
Assert.Narrower("Main navigation", "Page");
Assert.LeftOf("Main navigation", "Page");
// A group pins its children BESIDE the rail rather than over it.
Ui.Click("Settings sub-pages");
Assert.Visible("People");
Assert.RightOf("People", "Overview");Notes#
⚠ A hover-revealed affordance cannot be driven by a test today — there is no Ui.Hover verb, so the tip is
covered by a screenshot rather than an assertion. Everything reachable by a click or by focus is asserted normally.
⚠ railFoot is hidden in the docked bands. A 60px rail cannot hold a card, and a clipped card reads as a
rendering fault where its absence reads as a narrow rail.
See also#
- App shells — the shared
AppChromecontract every arrangement reads - TabbedShell — top tabs, and a bottom bar on a phone
- FocusedShell — one task, no navigation at all
- style props — the style-prop vocabulary, including
inside <Component>.<State> - accessibility — landmarks,
role:,current:and the naming props