Osy#betaRun it on Osyrin: osyrin.com

Reference / UI

App shells

SidebarShell(chrome) { Outlet(retain: 8); … } — one `AppChrome` declaration, four arrangements of it

An app shell is the frame around every page: a brand, a navigation tree, the signed-in person and a menu behind them, a search slot and a place for a page's own actions. You declare that ONCE, as an `AppChrome`, and hand it to the shell you want. Every shell takes the same value and the same slots, so changing arrangement is one word and you lose nothing but the layout.

stable3 examples compiled by CIuikitshellnavigation

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 not

Description#

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.

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 path when To == path, or when path starts with To + "/" — so /orders claims /orders/42 and 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. Set Exact = true on any other row that must not claim its own children;
  • exactly one row is current: the LONGEST match across the whole tree wins. With /settings and /settings/users both 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 shell

The 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 wantwhere it goes
the bar's titlenowhere — the shell reads the page's own [Title("…")] (or its last Navigation.SetTitle)
a page's own actionson the page, in its PageHead(…)
a right railthe layout's slot aside, keyed on the route, holding a ShellAside(…) — or the page's own two-column Row
global search, notifications, helpthe 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:

bandwidththe design
compact< 768the 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
cozy768+the rail DOCKS as a 60px icon strip — always visible — and a group opens as a FLYOUT beside it; one content column
wide1100+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#

shellnav on a pointernav on a phoneidentitywhen to reach for it
SidebarShell (this page)a persistent left rail; groups disclose in placean off-canvas drawera chip in the top barthe default for anything with more than about five sections
TabbedShell TabbedShella horizontal strip under the brand row; a group becomes a dropdowna bottom tab bar, plus a "More" sheeta chip in the brand rowa handful of peer sections, and a phone-heavy audience
RailShell RailShellicons only, always; labels arrive on hover and focusa labelled off-canvas drawerat the rail's foota tool where the canvas is the product
FocusedShell FocusedShellnone — the Nav becomes an ordered set of STEPSthe same, as "Step 2 of 4"minimal, beside the exita 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 setfalls back to
brand markMark = Icons.X (a glyph) or MarkSrc = "…" (an image)the product's first letter
nav row · menu rowIcon = Icons.XIcons.Folder · Icons.ChevronRight
the personAvatarSrc = "…"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#

Related

TabbedShell

An app shell whose primary navigation is a horizontal strip of tabs under the brand row, and a bottom tab bar within…

RailShell

An app shell built around a permanently narrow icon rail, in the shape Slack, Linear and Discord converge on. Every row…

FocusedShell

An app shell for completing a single task — a checkout, an approval, a configuration step, a guided flow. It is built…

layout primitives

The built-in layout primitives and how they arrange children. `Stack` stacks children in a column, `Row` lays them in a…

component

The one archetype for all UI: a bounded reactive unit — typed props, reactive members (fields, `live`…

routes and pages

How a component becomes a page: it declares a route with `[Route("/catalog/{slug}")]`, and navigating to a matching…

Navigation

The routes the user has open, which of them hold UNSAVED edits, and the verbs that move between them or close one…

Osysharp.Ui (the UI kit)

The bundled UI kit — ready-made styled controls like `Button`, the shared design-system vocabularies (`Tone`, `Size`)…

theme tokens

A `theme` block names your app's design tokens — colors, spacing, radii, and more — as reusable values. A token can…

accessibility

Tags already give an element its role, focus and keyboard behaviour. The semantic props say the rest: `role:` for a…

Dialog.Open / Dialog.Ask / Dialog.Confirm / Dialog.Discard

Opens a component in an overlay above the current screen, with a unit of work you choose: `Inherit` makes its edits a…