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); }
}
}
}
}
[Route("/")] [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."); }
}
}
[Route("/approvals")] [Layout(AppShell)] [Title("Approvals")] [Render(CSR)] [AllowAnonymous]
component Approvals() {
render {
PageHead("Approvals");
Card("Waiting") { Text("Three requests need a decision today."); }
}
}
[Route("/settings")] [Layout(AppShell)] [Title("Settings")] [Render(CSR)] [AllowAnonymous]
component Settings() { render { PageHead("Settings"); } }
[Route("/settings/people")] [Layout(AppShell)] [Title("People")] [Render(CSR)] [AllowAnonymous]
component People() { render { PageHead("People"); } }
[Route("/settings/billing")] [Layout(AppShell)] [Title("Billing")] [Render(CSR)] [AllowAnonymous]
component Billing() { render { PageHead("Billing"); } }
[Route("/settings/billing/invoices")] [Layout(AppShell)] [Title("Invoices")] [Render(CSR)] [AllowAnonymous]
component Invoices() { render { PageHead("Invoices"); } }
[Route("/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. Survives a narrow band:
// where the lockup is mark-only it moves to the accessible name, and Tabbed
// puts it under the page title on a phone
bool ShowVersion; // "factory · 1.0.8" over "platform 0.17.3" — App.Version / App.PlatformVersion
// in the brand (osy docs stdlib-app-version); off by default
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
bool Panes; // the page is a WORKSPACE of panes that scroll on their own (Sidebar, Rail) — see #panes
}
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; string To; Icons Icon; Tone Tone; bool Divided; } // `To` = a route, through the leave guard
// 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, shortcut)`
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#
Write the pages before the shell. Every nav row's To is checked against the routes the app declares, so a link
to a page not written yet is a compile error — "a nav link (NavItem.To): no page declares the route '/archive'.
This app's routes are: …" — not a dead link in the browser.
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 row that only goes somewhere names the route in To instead of an OnPress. The shell then goes there through
its leave guard, exactly as a nav row does: a page holding unsaved changes asks first (Leave without saving? — keep
editing · save and leave · discard and leave), and a clean page is left at once. An OnPress that calls
Navigation.Go leaves a dirty page without a word, so reach for To whenever the row navigates:
new MenuAction { Label = "Your account", Icon = Icons.User, To = "/account" }⚠ 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.
A workspace of panes — a list beside a record#
Most pages are DOCUMENTS: they scroll as a whole, and the shell pads them. A mail client, a helpdesk or a CRM's
record view is a WORKSPACE instead — a list beside the thing that is open, each scrolling on its own, with the bars
staying put. Panes = true says so: the work column becomes exactly the screen's height, the page fills it edge to
edge with no padding, and each pane takes h: "100%" and scrolls with overflowY: Overflow.Auto.
[Layout] [AllowAnonymous]
component Workspace() {
render {
SidebarShell(new AppChrome {
Product = "Harbour Desk", Panes = true,
Nav = [ new NavItem { Label = "Inbox", To = "/inbox", Icon = Icons.Inbox } ],
}) {
Outlet();
}
}
}
[Route("/inbox")] [Layout(Workspace)] [AllowAnonymous] [Render(CSR)]
component Inbox() {
render {
Row(align: Align.Stretch, h: "100%", minH: "0") {
Box(w: "360px", h: "100%", overflowY: Overflow.Auto, label: "The list") {
foreach (var i in Enumerable.Range(1, 200)) { Text("Message " + i, py: 2, px: 4); }
}
Stack(grow: 1, minW: "0", h: "100%", p: 5, label: "The open one") {
Text("The open message stays where it is while the list scrolls.");
}
}
}
}⚠ Put a list's rows in a Box, not a Stack. A component that is the only child of a scrolling Stack
stretches to fill it, so a one-row list tints the whole pane in its selected colour.
The work shells — SidebarShell and RailShell — honour it; the others are document shells and ignore it.
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 page may ask for the strip. AppChrome.Narrow = true says the page needs the room right now — a board, a
live view laid out across the window. SidebarShell then keeps the rail an icon strip up to Widescreen (1680)
instead of opening its labels at Laptop (1100). Set it from what the page is doing (Narrow = building), not
once. The reader's own control still opens the rail — for that visit — and from 1680 up the choice is theirs alone,
remembered in their browser as always.
⭐ A strip hides no state. In the icon strip a row with a Badge keeps a dot on its mark, in the badge's own
tone (Tone.Warning for something waiting on the reader, Tone.Danger for something wrong), and the badge's words
join the row's accessible name — "Orders — 3 waiting". And rows that are things of one kind — projects,
workspaces — can carry NavItem.Monogram ("BC"): drawn in place of the glyph where there are no words, because five
of the same glyph say nothing.
⭐ 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.
⭐ A row without its label is marked by a SQUARE, not by a band. When there are words, the current row's highlight is the whole row; when the rail is down to glyphs — the docked rail, a collapsed sidebar — the highlight is a square around the glyph, concentric with it and the same size as the brand tile at the head of the same column. The two forms are one rule, so every arrangement marks a label-less row the same way.
⚠ The square is the PAINT, not the target. The row it sits in keeps its full Length.TouchDense box, so a
narrow rail is not a harder rail to hit — only a quieter one to read.
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); }
}
}
[Route("/archive")]
component Archive() { render { Text("Archived files"); } } // every nav row's page exists — see #nav⚠ 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
- Pinned and recent pages, the app switcher, and presence in a shell — pinned and recent pages, the app switcher, presence
- layout primitives —
Stack,Row,Box, and howgap/align/justifywork - routes and pages —
[Route],[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