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

RailShell

RailShell(chrome) { Outlet(retain: 8); … } — a permanent icon rail whose labels arrive on hover and focus

An app shell built around a permanently narrow icon rail, in the shape Slack, Linear and Discord converge on. Every row reveals its label on hover and on keyboard focus, groups open a flyout beside the rail, and the signed-in person sits at the rail's foot rather than in the top bar.

stable1 example compiled by CIuikitshellnavigation

Summary#

RailShell is a permanently narrow icon rail — 60px, at every pointer width, with no collapse control. Reach for it when the canvas is the product and the navigation should take as little of it as possible, and when the app has few enough top-level sections that a glyph each is genuinely legible.

[Layout]
[AllowAnonymous]
component RailLayout() {
  action Palette() { }
  action SignOut() { Session.SignOut(); }

  render {
    RailShell(new AppChrome {
      Product = "Expensely",
      Home = "/",
      User = new ShellUser {
        Name = "Olivia Rhye", Secondary = "Finance manager", Initials = "OR",
        Menu = [ new MenuAction { Label = "Sign out", Icon = Icons.Logout, OnPress = SignOut, Tone = Tone.Danger } ],
      },
      Nav = [
        new NavItem { Label = "Overview", To = "/", Icon = Icons.Home, Exact = true },
        new NavItem { Label = "Approvals", To = "/approvals", Icon = Icons.CheckCircle, Badge = "12", BadgeTone = Tone.Warning },
        new NavItem { Kind = NavKind.Section, Label = "Administration", Children = [
          new NavItem { Label = "Settings", To = "/settings", Icon = Icons.Gear, Children = [
            new NavItem { Label = "People", To = "/settings/people", Icon = Icons.Users },
          ] },
        ] },
      ],
    }) {
      Outlet(retain: 8);
      slot search { ShellSearch("Search", onPress: Palette); }
    }
  }
}

[Page("/")] [Layout(RailLayout)] [Title("Overview")] [Render(CSR)] [AllowAnonymous]
component ROverview() { render { PageHead("Overview"); Card("This month") { Text("Nothing needs you."); } } }

[Page("/approvals")] [Layout(RailLayout)] [Title("Approvals")] [Render(CSR)] [AllowAnonymous]
component RApprovals() { render { PageHead("Approvals"); } }

[Page("/settings")] [Layout(RailLayout)] [Title("Settings")] [Render(CSR)] [AllowAnonymous]
component RSettings() { render { PageHead("Settings"); } }

[Page("/settings/people")] [Layout(RailLayout)] [Title("People")] [Render(CSR)] [AllowAnonymous]
component RPeople() { render { PageHead("People"); } }

Signature#

RailShell(AppChrome chrome) {
  Outlet(retain: 8);          // the routed page
  slot search   { … }         // the top bar
  slot actions  { … }         // the top bar, right of search
  slot railFoot { … }         // the phone DRAWER only — a 60px rail cannot hold a card
  slot aside    { … }         // beside the page at 1100+, under it below that
  slot primary  { … }         // a task's commit bar — `ShellTaskBar`. See [FocusedShell](/reference/ui/shell-focused/)
}

Description#

How is this not the sidebar collapsed?#

SidebarShell narrows to an icon strip as a state — the reader chooses it, the wide band un-chooses it, and while it is narrow a row shows a glyph and nothing else. RailShell is narrow always, and is built around that constraint rather than tolerating it. Three things differ, and all three are mechanism:

  • Every row carries its label, as a flyout on hover and on keyboard focus. The sidebar's collapsed rail has none, which makes an icon-only nav a memory test — the single biggest reason narrow rails have a bad reputation.
  • The identity lives at the rail's foot, not in the top bar. That is not decoration: it frees the entire top bar for page context, which is what lets this arrangement give a page a genuinely wide, uncluttered header.
  • It expands into a panel that FLOATS. Press "Expand navigation" at the rail's foot and the 60px rail widens into a labelled panel over the page. The content column never reflows, because the 60px column stays exactly where it was and the panel is a layer you dismiss rather than a resize you undo.

Floating rather than pushing is what keeps these two shells two shells. SidebarShell already expands by changing its width, with the page reflowing each time; that is its model and a good one for a shell whose nav is the app's primary structure. A rail that did the same would be SidebarShell with a smaller collapsed width and no reason to exist.

Expanding the rail#

The control sits at the foot of the rail, above the identity — chrome about the rail belongs with the rail's other chrome, not among the destinations (a row that is not a place) and not crowding a 60px brand tile.

It is a real button with a changing accessible name — "Expand navigation" / "Collapse navigation" — and it carries expanded:, so assistive technology is told which state it is in and a test can drive it. An expansion only a mouse can reach is not an expansion.

Assert.Hidden("Approvals", within: "Main navigation");   // narrow: glyphs only, the label is a hover flyout
Ui.Click("Expand navigation");
Assert.Visible("Approvals", within: "Main navigation");  // the panel labels every row in place
Ui.Click("Collapse navigation");

While expanded, every row shows its label and badge in the row itself, railFoot appears (a panel has the width for a card), and the identity chip shows the person's name rather than only their mark. Following any row collapses the panel again — the panel covers the page, and the page is what you just asked for.

Expansion is a pointer affordance only. On a phone the drawer already shows every label, so there is nothing to expand into and the control is not drawn.

How a label appears with no JavaScript#

The tip is an ancestor-conditioned variant: a descendant's base block names an ancestor component and a pseudo-state, and it lowers to a plain CSS descendant rule.

variants {
  base { Display = Display.None; Position = Position.Absolute; Left = "100%";
         inside RailNavLink.Hover { Display = Display.Flex; }
         inside RailNavLink.Focus { Display = Display.Flex; } }
}

The .Focus twin is not optional. A rail whose labels exist only under a pointer is unusable from the keyboard. And :focus matches only the focusable element itself, so the ancestor named here has to be the Link — a wrapper element as the component's root would make the keyboard half silently dead.

Display.None, never Opacity = 0. A merely transparent tip keeps its text in the layout and in textContent, so every rail label would read as on-screen to Assert.Visible whether or not anybody could see it.

What does a group do?#

A group's children open a flyout beside the rail, pinned by a click — not by hover. A hover-only disclosure does not exist on a touch screen, so its children would be unreachable, which is the same as not shipping them. Hover and focus reveal the row's label, which is purely informational and safe to leave to the pointer.

A group carrying a To navigates as well as disclosing: a link with a thin chevron strip down its trailing edge, named "Settings sub-pages" so the two targets in one row do not answer to the same words. Anything nested deeper than the flyout draws is flattened into it rather than dropped.

This is the one shape a rail keeps that a tab strip does not, and the difference is the model. A rail row is a row in a list — disclosing under or beside it is what a list does, and the flyout exists because 60px has no room for the children's names. A tab is a destination, so TabbedShell refuses the same mechanism and puts a section's areas in a secondary strip instead. Expanding the rail closes any pinned flyout, because the panel shows those names in place and the same information twice is worse than once.

What changes on a phone?#

The rail becomes a labelled off-canvas drawer opened from the top bar — a drawer has the width for words, so the rows are labelled there rather than icon-only, and railFoot appears because a drawer can hold a card.

⚑ This is close to the sidebar's compact band on purpose. On a phone a left-nav app is a drawer; inventing a difference in order to look different would be a worse design. What the arrangement keeps is its own identity placement — the person is at the drawer's foot, not in the top bar.

Examples#

// The rail is narrow — the claim that distinguishes the arrangement, and the only one geometry can check.
Assert.Narrower("Main navigation", "Page");
Assert.LeftOf("Main navigation", "Page");

// A group pins its children BESIDE the rail rather than over it.
Ui.Click("Settings sub-pages");
Assert.Visible("People");
Assert.RightOf("People", "Overview");

Notes#

A hover-revealed affordance cannot be driven by a test today — there is no Ui.Hover verb, so the tip is covered by a screenshot rather than an assertion. Everything reachable by a click or by focus is asserted normally.

railFoot is hidden in the docked bands. A 60px rail cannot hold a card, and a clipped card reads as a rendering fault where its absence reads as a narrow rail.

See also#

  • App shells — the shared AppChrome contract every arrangement reads
  • TabbedShell — top tabs, and a bottom bar on a phone
  • FocusedShell — one task, no navigation at all
  • style props — the style-prop vocabulary, including inside <Component>.<State>
  • accessibility — landmarks, role:, current: and the naming props

Related

App shells

An app shell is the frame around every page: a brand, a navigation tree, the signed-in person and a menu behind them, a…

TabbedShell

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

FocusedShell

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

style props

Inside a `variants` block, each `Name = value` is a style prop from a fixed vocabulary the renderer maps to CSS — paint…

accessibility

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