Osy#betaa language · its runtime Osyrin · a hosted platform
Why Osy#Built for agentsAgents as declarationsWorkflows that waitRuns exactly onceSecure by defaultNothing to mockThe editor is the compilerUI in the languageDocuments are dataOne program

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.

stable2 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); }
      }
    }
  }
}

[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 pageyour `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 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.

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/chainsAdministration  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 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.

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 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#

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 keyboardnav, 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 widthminW: "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); }
  }
}

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 `[Page("/catalog/{slug}")]`, and navigating to a matching path…

Navigation

The routes the user currently has open, and the verbs that move between them. Read `Navigation.Routes` in a layout to…

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…