Summary#
A shell is the frame every page renders inside. You declare what it holds once — in your [Layout] — and hand
that one value to whichever arrangement you want:
[Layout]
[AllowAnonymous]
component AppShell() {
bool accountOpen = false;
string accountName = "Olivia Rhye";
action OpenAccount() { accountOpen = true; }
action CloseAccount() { accountOpen = false; }
action Appearance() { Theme.Toggle(); }
action SignOut() { Session.SignOut(); }
action Palette() { }
action Inbox() { }
action Help() { }
render {
SidebarShell(new AppChrome {
Product = "Expensely",
Tagline = "Finance operations",
Mark = Icons.Chart,
Home = "/",
User = new ShellUser {
Name = "Olivia Rhye",
Secondary = "Finance manager",
Initials = "OR",
Menu = [
new MenuAction { Label = "Account", Icon = Icons.User, OnPress = OpenAccount },
new MenuAction { Label = "Appearance", Icon = Icons.Eye, OnPress = Appearance },
new MenuAction { Label = "Sign out", Icon = Icons.Logout, OnPress = SignOut, Tone = Tone.Danger, Divided = true },
],
},
Nav = [
new NavItem { Label = "Dashboard", To = "/", Icon = Icons.Home },
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 },
new NavItem { Label = "Billing", To = "/settings/billing", Icon = Icons.Tag, Children = [
new NavItem { Label = "Invoices", To = "/settings/billing/invoices", Icon = Icons.File, Children = [
new NavItem { Label = "Drafts", To = "/settings/billing/invoices/drafts", Icon = Icons.Pencil },
] },
] },
] },
] },
],
}) {
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); } }
slot railFoot {
ShellFootCard("Need a hand?", "Read the expense guides, or ask us.", "Visit help centre", onPress: Help);
}
slot aside {
if (Navigation.CurrentPath == "/approvals") {
ShellAside("Waiting on you") {
Text("Beside the page", fontWeight: FontWeight.Semibold);
Text("Three requests need a decision today.", fontSize: FontSize.Caption, color: Colors.TextMuted);
}
}
}
}
if (accountOpen) {
Dialog("Account", onDismiss: CloseAccount) {
Field("Display name", value: accountName);
slot actions { Button("Save", onPress: CloseAccount, tone: Tone.Primary); }
}
}
}
}
[Page("/")] [Layout(AppShell)] [Title("Dashboard")] [Render(CSR)] [AllowAnonymous]
component Home() {
render {
PageHead("Dashboard", subtitle: "What is waiting on you.");
Card("This month") { Text("Nothing needs you right now."); }
}
}
[Page("/approvals")] [Layout(AppShell)] [Title("Approvals")] [Render(CSR)] [AllowAnonymous]
component Approvals() {
render {
PageHead("Approvals");
Card("Waiting") { Text("Three requests need a decision today."); }
}
}
[Page("/settings")] [Layout(AppShell)] [Title("Settings")] [Render(CSR)] [AllowAnonymous]
component Settings() { render { PageHead("Settings"); } }
[Page("/settings/people")] [Layout(AppShell)] [Title("People")] [Render(CSR)] [AllowAnonymous]
component People() { render { PageHead("People"); } }
[Page("/settings/billing")] [Layout(AppShell)] [Title("Billing")] [Render(CSR)] [AllowAnonymous]
component Billing() { render { PageHead("Billing"); } }
[Page("/settings/billing/invoices")] [Layout(AppShell)] [Title("Invoices")] [Render(CSR)] [AllowAnonymous]
component Invoices() { render { PageHead("Invoices"); } }
[Page("/settings/billing/invoices/drafts")] [Layout(AppShell)] [Title("Drafts")] [Render(CSR)] [AllowAnonymous]
component Drafts() { render { PageHead("Drafts"); } }SidebarShell is the arrangement. TabbedShell, RailShell and FocusedShell take the same AppChrome and
the same slots, so switching is that one word — see what each arrangement does.
Signature#
SidebarShell(chrome) · TabbedShell(chrome) · RailShell(chrome) · FocusedShell(chrome)
class AppChrome {
string Product; // the product's name
string Tagline; // a second line — the workspace, the tenant, the environment
Icons? Mark; // the brand mark as a GLYPH — yours or a built-in; unset draws the product's initial
string MarkSrc; // …or as an IMAGE — a full-colour logo, a tenant's own. Wins over `Mark`
string Home; // where the brand lockup goes when pressed
NavItem[] Nav; // the navigation TREE
ShellUser User; // who is signed in, or null for nobody
bool Loading; // draw skeleton nav rows instead of an empty column
}
class NavItem {
string Label; string To; Icons Icon; // a built-in, or any `.svg` your app ships
NavItem[] Children; // non-empty ⇒ a GROUP, unless `Kind` says Section
NavKind Kind; // Link (default) · Group (inferred) · Section
string Badge; Tone BadgeTone;
bool Exact; // match this route exactly, never as a prefix
}
class ShellUser { string Name, Secondary, Initials, AvatarSrc; MenuAction[] Menu; }
class MenuAction { string Label; Action OnPress; Icons Icon; Tone Tone; bool Divided; }
// the slots — the same six on every shell. A slot reserves a POSITION; the control you put in it
// brings the chrome, which is why an unfilled slot costs nothing.
(default) the routed page — your `Outlet(retain: n)`
search a global search affordance · `ShellSearch(placeholder, onPress, hint)`
actions top-bar controls · `ShellCountButton(label, count, onPress) { Icon(…); }`
railFoot pinned at the foot of a rail · `ShellFootCard(title, body, actionLabel, onPress)`
aside a right rail · `ShellAside(label) { … }` — a column beside the page where
there is room, a section under it where there is notDescription#
What an app declares, and what a shell decides#
AppChrome is what; the shell is how. The app says "these are my sections, this is who is signed in, this
is my product"; the shell decides whether that becomes a left rail, a tab strip, an icon strip or nothing at all.
That split is why the nav is data and not slot children. A Nav { NavItem(…) NavItem(…) } block reads nicely
and cannot be REARRANGED — the child components would decide their own layout, so a tabbed shell handed a rail's
children renders a rail. A shell has to be able to walk the tree, flatten it, nest it, or drop it. Everything a
shell genuinely cannot rearrange — a search box, a page's own buttons — stays a slot.
⚠ Build the chrome at the CALL SITE, not in a live var. A MenuAction carries an Action, and an action may
only be set where the platform can run it. Hoisting the whole new AppChrome { … } into a live var is refused,
and the refusal says so.
How a nav row knows it is the page you are on#
Active state is derived from the route, never passed in per page. Getting this wrong is the single thing that
makes a shell feel fake, so the rule is stated once, in AppChrome.CurrentRoute, and every shell uses it:
- a row matches
pathwhenTo == path, or whenpathstarts withTo + "/"— so/ordersclaims/orders/42and never/orders-archive; /is exempt from prefix matching. The home route is a prefix of every path in the app, so without this Home is lit on every page. SetExact = trueon any other row that must not claim its own children;- exactly one row is current: the LONGEST match across the whole tree wins. With
/settingsand/settings/usersboth in the nav, a per-row test lights both on the child's page, and two current rows read as a bug; - every ancestor of the current row is on the trail — a lighter treatment, and it OPENS.
How deep can a nav go? As deep as you like. A shell draws three levels of INDENT — section, group, leaf — and flattens everything below the third to it, so a row five levels down appears beside its parent rather than disappearing. Active state is computed over the whole tree at any depth, by the same walk that decides what renders, so the row you are standing on is marked wherever it sits.
/settings/policy/chains ⇒ Administration section, shown
Settings group, on the trail, OPEN
Policy group, on the trail, OPEN
Approval chains CURRENT⭐ A deep link arrives with its ancestors already open. Landing straight on /settings/policy/chains — from a
bookmark, from an email — must not leave the current row hidden inside a collapsed group. A group is open when it
holds the current route; a reader who then closes it is remembered, so it does not spring back.
The person, and the menu behind them#
ShellUser is what a shell shows. The app supplies it: Session.CurrentUser is the obvious source, but which of
your own columns is the NAME and which is the ROLE is your app's question, not the kit's.
Menu is the app's too — the shell never hardcodes which entries exist, so an app with no billing has no Billing
row. Each MenuAction carries a verb, and a verb may open a dialog:
new MenuAction { Label = "Account", Icon = Icons.User, OnPress = OpenAccount }
action OpenAccount() { accountOpen = true; } // …and render a `Dialog` beside the shellThe shell closes its own menu and drawer before running your action, so a dialog opens over a clean page. Render
the Dialog next to the shell in your layout, not inside it — nothing the rail does with transforms or overflow can
then clip it.
⚠ A user with no Menu gets a LABEL, not a button. A chip that opens nothing is an affordance that lies.
What a page contributes, and what it cannot#
A page renders inside an Outlet, so it cannot fill a slot of the shell that hosts it. Two consequences worth
knowing before you design around them:
| you want | where it goes |
|---|---|
| the bar's title | nowhere — the shell reads the page's own [Title("…")] (or its last Navigation.SetTitle) |
| a page's own actions | on the page, in its PageHead(…) |
| a right rail | the layout's slot aside, keyed on the route, holding a ShellAside(…) — or the page's own two-column Row |
| global search, notifications, help | the layout's search / actions / railFoot slots |
The title is the one worth pausing on: a page names itself once, with [Title], and every shell's bar follows. There
is nothing to keep in sync, and a page that declares no title leaves the bar empty rather than inventing one from
the URL.
What changes at each width?#
A shell is not one layout that stretches. SidebarShell reads the band once per render and everything follows
from it — the rail's width, whether a group is an accordion or a flyout, whether a menu is a dropdown or a sheet,
whether aside is a column or a section:
| band | width | the design |
|---|---|---|
| compact | < 768 | the rail is an off-canvas DRAWER over a scrim; a menu button in the bar; menus are bottom SHEETS within thumb reach; aside stacks under the page |
| cozy | 768+ | the rail DOCKS as a 60px icon strip — always visible — and a group opens as a FLYOUT beside it; one content column |
| wide | 1100+ | the rail opens to 264px with labels, badges and section headings; the page and its aside sit SIDE BY SIDE |
⭐ The wide band is not the narrow one stretched. A single centred column at 1440px is a phone layout on a desktop: dead space, and one thing visible at a time. A wide screen should show MORE — that is what it is for.
⭐ And the compact band is not the wide one squeezed. Every target is at least Length.Touch (44px), nothing
load-bearing is reachable only by hovering, and overlays are sheets rather than dropdowns pinned to a corner a thumb
cannot reach.
What each arrangement does with the same declaration#
| shell | nav on a pointer | nav on a phone | identity | when to reach for it |
|---|---|---|---|---|
SidebarShell (this page) | a persistent left rail; groups disclose in place | an off-canvas drawer | a chip in the top bar | the default for anything with more than about five sections |
TabbedShell TabbedShell | a horizontal strip under the brand row; a group becomes a dropdown | a bottom tab bar, plus a "More" sheet | a chip in the brand row | a handful of peer sections, and a phone-heavy audience |
RailShell RailShell | icons only, always; labels arrive on hover and focus | a labelled off-canvas drawer | at the rail's foot | a tool where the canvas is the product |
FocusedShell FocusedShell | none — the Nav becomes an ordered set of STEPS | the same, as "Step 2 of 4" | minimal, beside the exit | a wizard, a checkout, a reader |
⚠ FocusedShell drops NAVIGATION, not the nav DATA, and the distinction is the whole reason switching to it
costs nothing. A focused flow has one job and a rail beside it is an invitation to leave — so there is no rail. But
the same Nav you already declared is re-read as a linear FLOW: a top-level entry is a step, in order, and its
children are that step's parts. You declare nothing new to switch, and nothing is lost switching back.
⚠ THIS TABLE DESCRIBED FOUR SHELLS WHILE ONE EXISTED, from 2.3.0 until 2.5.0. Two rows were wrong when the
other three actually shipped and are corrected above: TabbedShell's identity is in the brand row rather than at
the strip's end (the strip needs its full width for tabs), and FocusedShell shows the Nav as steps rather than
hiding it. Read a version note as a promise until the thing exists.
The standard a shell has to meet#
This is the bar SidebarShell was built to, and what the other arrangements are held to. It is here rather than in
a brief because a standard that lives in a brief expires; a downloader forking a shell should be able to read it.
Responsive — three bands, each designed on its own terms. Judge each width by "is this the best layout for a screen this size", never by "does it survive being resized to this".
Touch — every interactive target at least Length.Touch; Length.TouchDense only where the neighbours are the
same control and a miss is harmless. Nothing load-bearing behind a hover: a hover-only disclosure does not exist on
a touch screen, so a group needs a tap path at every width.
Overlays — a drawer sits over its own scrim (ZIndex.Drawer) and under a dialog (ZIndex.Modal). Opening one
overlay closes the others the shell owns, so nothing is left hanging underneath. On a phone a menu is a bottom
sheet, and a sheet still traps focus, still closes on Escape, and still returns focus where it came from.
Active state — derived from the route by longest match, never hand-passed. Exactly one row current; ancestors on the trail and open; a deep link arrives open.
Landmarks and keyboard — nav, banner and main regions, each NAMED (a page with two unnamed nav regions
is unnavigable). A visible focus ring on every control — replace the default, never remove it. current: on the
current nav row, so a screen reader announces "current page" and not just a colour.
Overflow — a long product name, a 35-character nav label, twenty rows, a user with no avatar and an empty nav
all leave the layout intact. Truncate with intent: TextOverflow.Ellipsis needs WhiteSpace.Nowrap and
Overflow.Hidden beside it or there is nothing to clip. The page body never scrolls sideways at any width —
minW: "0" on the work column is what lets a wide table scroll inside it instead of shoving the shell.
Images — the brand mark, a nav glyph and an avatar are the three, and an app can supply ALL of them: one
vocabulary (Icons, which holds the app's own .svg files beside the built-ins) for anything drawn as a glyph, one
…Src string for anything whose address is only known at runtime, and a derived fallback for each so nothing is
required to get a shell. ⚠ A shell that lets an app choose its user's photograph but not its own logo has the
priority backwards — that was true here until 2.4.0, and it is written down so the next arrangement does not
inherit it.
Both themes — every colour from a token, never a literal, and dark checked rather than assumed. Borders and elevation are where a dark theme fails first.
Hierarchy — primary, secondary and destructive actions visually distinct; nav sections carry group labels; icons
consistently sized and optically centred. Motion from Motion.Fast / Motion.Normal: a shell with no transitions
feels dead, one with slow transitions feels cheap.
Loading and empty — the shell owns the states it can own. chrome.Loading draws skeleton nav rows rather than an
empty column, and an empty Nav draws a sentence rather than a blank rail.
A slot is a POSITION, never chrome — and this one is a rule rather than a preference, because breaking it is
invisible. A shell reserves where optional content goes; the width, the border, the fill and the sticky box arrive
with the content, in the control the app puts there — ShellSearch, ShellCountButton, ShellFootCard,
ShellAside. A shell that draws the box itself draws it whether or not anyone filled the slot: SidebarShell did
exactly that for aside and cost every app that ignored it 26% of a 1280 screen, a full-height hairline and a
band of Surface, for an empty region.
⚠ And you cannot fix that by asking whether the slot was filled. The renderer collapses an element whose whole
content is an unfilled named slot, but it deliberately stops short of a container whose contents can arrive —
collapsing one would move layout under the reader. An aside is exactly that container: it is keyed on the route, and
a route-keyed fill is an if, which leaves an anchor whichever way it goes. So a "was it filled?" answer is
computed once, at build, and is wrong the moment the reader navigates. Let the caller bring the box and the question
never arises.
It is a floor, not a ceiling#
Every shell is ordinary Osy# whose source ships. osy kit SidebarShell prints it, and declaring a component of the
same name in your own app shadows it whole — nothing here is privileged and nothing is unforkable. The interior
pieces are marked [Part], so they stay out of osy kit's catalogue while remaining just as forkable.
Fork when you outgrow it; compose first. Most of what a shell needs varying is already a slot or a field.
The three images a shell draws#
A shell shows a brand mark, a glyph per nav row and menu row, and the signed-in person's avatar. There is one vocabulary and one fallback rule for all of them, and both are worth learning once:
| what you set | falls back to | |
|---|---|---|
| brand mark | Mark = Icons.X (a glyph) or MarkSrc = "…" (an image) | the product's first letter |
| nav row · menu row | Icon = Icons.X | Icons.Folder · Icons.ChevronRight |
| the person | AvatarSrc = "…" | Initials, then a person glyph |
⭐ Icons is YOUR vocabulary, not a kit one. Every .svg under your app's icons glob is a member of it
beside the built-ins, under the name of the file — icons/receipt.svg is Icons.Receipt — and the shell holds
exactly that type. So an app icon and a built-in are written the same way, in the same field, and nothing about a
nav row prefers the ones the platform happens to ship:
// `Icons.Logo` and `Icons.Chev` are this app's OWN files — `model/icons/logo.svg` and `model/icons/chev.svg`.
// Neither is a built-in, and neither is spelled differently from `Icons.Home` two lines down.
[Layout]
[AllowAnonymous]
component BrandedShell() {
render {
SidebarShell(new AppChrome {
Product = "Ledger", Home = "/", Mark = Icons.Logo,
Nav = [
new NavItem { Label = "Files", To = "/", Icon = Icons.Home },
new NavItem { Label = "Archive", To = "/archive", Icon = Icons.Chev },
],
}) { Outlet(retain: 4); }
}
}⚠ A GLYPH TAKES THE SHELL'S COLOUR; AN IMAGE KEEPS ITS OWN. Mark is drawn like every other icon — one colour,
inherited — on the brand tile. A logo with colours of its own is not a glyph, so it goes in MarkSrc, which
replaces the tile rather than tinting what you put on it.
⭐ MarkSrc is also how a per-tenant logo works. It is a plain src, so it can come from a row: a white-label
app reads its tenant's logo at render time and hands it over, exactly as it already does for a user's photograph.
That is the reason AvatarSrc stays a string rather than folding into Icons — a design asset is known when you
compile, and a person's photograph is not.
What it cannot do yet#
⚠ A slot cannot hold the brand mark. Mark/MarkSrc cover a glyph and an image; an arbitrary node there — a
full-colour inline Svg(Art.X), a wordmark you want to lay out yourself — needs a fork of ShellBrand, which
osy kit ShellBrand prints for you.
See also#
- TabbedShell — top tabs on a pointer, a bottom tab bar on a phone
- RailShell — a permanent icon rail with flyout labels
- FocusedShell — one task, no navigation, the nav read as progress
- layout primitives —
Stack,Row,Box, and howgap/align/justifywork - routes and pages —
[Page],[Layout],Outletand retention - Navigation —
Navigation.Routes,[Title], and what a shell reads from them - Dialog.Open / Dialog.Ask / Dialog.Confirm / Dialog.Discard —
Dialog.Openand the unit-of-work choice a modal makes - accessibility — landmarks,
role:,current:and the naming props - Osysharp.Ui (the UI kit) — the rest of the controls a shell frames