App shell
NewThe Orbit app frame: the merchant's store and navigation on the porcelain ground, a slim header across the top, and every page on one white canvas raised on it. Use it once, at the root of every Orbit dashboard.
The frame, live
A whole shell for an invented shop, Harbour Desk Co, in a fixed-height frame. Move between pages and watch the white pill glide, open the store switcher at the foot, press Receive an order, and fold the sidebar with the toggle beside the logo or the [ key. On this page [ folds this demo frame; the docs site's own sidebar is held open.
Orders
This is the canvas, the one raised surface. It scrolls on its own while the sidebar and header stay put.
- Mode
- desktop
- Sidebar
- expanded
- Orders to fulfil
- 12
- Nadia AhmedUnfulfilled£579.00
- Isaac BrownFulfilled£240.00
- Grace TurnerUnfulfilled£95.00
- Ewan GrantShipped£168.00
- Holly PriceUnfulfilled£27.99
- Ben ColeFulfilled£310.00
- Zara HussainUnfulfilled£52.50
- Callum ReidShipped£64.00
- Lucy BennettUnfulfilled£120.00
Anatomy
Two planes: the ground (sidebar and header) and the raised canvas. Every part is a separate component, so hosts compose their own sidebar and header.
| Part | What it is |
|---|---|
AppShell | The grid: sidebar on the left, header across the top, the canvas in the rest. Full height (100dvh). Owns the folded rail, the phone drawer and the [ key. |
Sidebar | The navigation column on the ground, 256px wide or a 68px icon rail. One white pill marks the page you are on and one soft pill follows the pointer. |
SidebarHeader | The 60px row at the top, lined up with the header. Holds the app's lockup (ShellBrand, linking home) and the SidebarToggle. |
ShellBrand | The app's lockup, top left: the Orbit mark, the app's name (name="Store Dashboard") and "by Orbit Commerce". In the rail it shrinks to the mark, and the toggle shows over it on hover. |
SidebarNav | The scrolling list of sections. Items fade under its top and bottom edges rather than being cut off. |
SidebarSection | A group with a quiet sentence-case title. In the rail the title becomes a short hairline tick. |
SidebarItem | A 32px row: icon, label and an optional count. Holds one level of nested items. |
SidebarFooter | The foot of the sidebar: the merchant's store, as a StoreButton that opens the host's store switcher upwards. |
StoreButton | The merchant's store: logo or initials, name and domain, with a chevron. In the rail, only the logo. |
TopBar | The 60px header on the ground: breadcrumbs at the start, the search pill in the centre, actions at the end. |
Breadcrumbs | Where you are, in 28px pills. The last crumb is the current page. |
Canvas | The raised white page, inset 8px from the edges with 18px corners. It scrolls inside itself, so the frame never moves. |
Putting it together
import {
AppShell, Canvas, Sidebar, SidebarHeader, SidebarNav, SidebarSection, SidebarItem,
SidebarFooter, StoreButton, ShellBrand, SidebarToggle, TopBar, Breadcrumbs, SearchTrigger, Avatar,
} from '@orbitcommerce/constellation'
import { HouseIcon, ReceiptIcon } from '@orbitcommerce/constellation/icons'
import { Link, NavLink } from 'react-router'
<AppShell
sidebar={
<Sidebar label="Harbour Desk Co">
<SidebarHeader>
<Link to="/">
<ShellBrand placement="header" name="Store Dashboard" />
</Link>
<SidebarToggle />
</SidebarHeader>
<SidebarNav>
<SidebarSection>
<SidebarItem label="Home" icon={<HouseIcon />} activeIcon={<HouseIcon weight="duotone" />}
link={<NavLink to="/" />} active={path === '/'} />
</SidebarSection>
<SidebarSection title="Commerce">
<SidebarItem label="Orders" icon={<ReceiptIcon />} activeIcon={<ReceiptIcon weight="duotone" />}
link={<NavLink to="/orders" />} active={path === '/orders'} current={path.startsWith('/orders/')}
badge={toFulfil} badgeLabel={`${toFulfil} orders to fulfil`} toggleLabel="Show order pages">
<SidebarItem label="Drafts" link={<NavLink to="/orders/drafts" />} active={path === '/orders/drafts'} />
<SidebarItem label="Returns" link={<NavLink to="/orders/returns" />} active={path === '/orders/returns'} />
</SidebarItem>
</SidebarSection>
</SidebarNav>
<SidebarFooter>
{/* The host's store switcher: a Menu opening upwards, triggered by the StoreButton. */}
<StoreButton
name="Harbour Desk Co"
detail="harbourdesk.co.uk"
avatar={<Avatar shape="square" size="sm" name="Harbour Desk Co" />}
/>
</SidebarFooter>
</Sidebar>
}
header={
<TopBar
start={<Breadcrumbs items={[{ label: 'Orders', link: <Link to="/orders" /> }, { label: 'Returns' }]} />}
center={<SearchTrigger onOpen={() => setSearchOpen(true)} />}
end={<>{/* ghost icon buttons, View store, the account avatar */}</>}
/>
}
>
<Canvas>{/* the page */}</Canvas>
</AppShell>Sidebar items
active marks the page you are on: the white pill settles there, the label turns ink and the icon swaps to its duotone activeIcon in teal. current marks a parent whose child page is active: it reads as current without taking the pill. Here Returns is active and Orders is current.
The count bumps and the Orders icon sends out one ping. In the rail the count is a teal dot.
Nested items
Children hang off a hairline orbit that runs through the icons' centre, and a small teal satellite rides the active nested pill. A parent with its own page (Orders) keeps its link and gets a separate chevron button to open and close; a parent without one (Catalogue) is a button that opens its children. Groups open and close with open, defaultOpen and onOpenChange, and stay closed in the rail. One level of nesting is all there is.
Counts
badge puts a small count on the right, in teal numerals on a teal tint. When a numeric badge goes up, it bumps and the icon sends out one ping, so a new order is noticed without a toast. The count is hidden from the item's name; badgeLabel gives screen readers the full meaning, such as "12 orders to fulfil". In the rail the count shrinks to a 7px teal dot.
Links
Pass your router's link as link, for example link={<NavLink to="/orders" />}. The shell fills it with the icon, label and count, adds its classes, and sets aria-current="page" when the item is active. Without link the row is a button, and onSelect runs on every press either way.
Expanded and the icon rail
Folded, the sidebar becomes a 68px rail: labels fade out, section titles become hairline ticks, counts become dots, the store shows only its logo, and each item's name moves into a tooltip on the right. The columns resize on the settle spring over 480ms. Both states side by side, in both themes:
- [ folds and unfolds the rail on desktop and tablet. It is ignored while someone is typing in a field, and when a modifier key is held.
SidebarToggledoes the same from beside the logo. In the rail it waits over the mark and shows on hover or keyboard focus. Its tooltip shows the [ key.- Leave the state to the shell, or control it with
collapsedandonCollapsedChangeto remember the merchant's choice.
const [folded, setFolded] = useState(() => localStorage.getItem('sidebar') === 'rail')
<AppShell
collapsed={folded}
onCollapsedChange={(next) => {
setFolded(next)
localStorage.setItem('sidebar', next ? 'rail' : 'expanded')
}}
sidebar={…}
header={…}
>Tablet and phone
The shell reads the window width, reports it as mode, and changes shape only on phones.
| Window | Mode | What changes |
|---|---|---|
| 1024px and wider | desktop | The 256px sidebar, folding to the 68px rail with [ or the toggle. |
| 768 to 1023px | tablet | Nothing: the sidebar and its rail stay as on desktop. The mode is there if a host wants to fold the sidebar by default. |
| Below 768px | mobile | The sidebar becomes a drawer: up to 304px or 86% of the width, rounded 22px on its open edge, on the modal shadow, sliding on the settle spring over a dimmed, lightly blurred scrim. The header gains a 36px menu button and drops its centre, breadcrumbs show only the current page, and the canvas insets 6px with 16px corners. |
- The menu button opens the drawer; tapping the scrim or pressing Escape closes it, and so does widening the window past 768px.
- Close the drawer yourself when a page is chosen: call
setDrawerOpen(false)fromuseShell()in the item'sonSelect, as the frame above does. - On phones the rail does not exist:
collapsedreads false and[does nothing. - Drop the actions that do not fit a phone header, the way Orbit Commerce's store dashboard drops View store, the plan pill and help on phones.
useShell and useOptionalShell
Anything inside AppShell can read and drive the frame. useShell() throws outside a shell; useOptionalShell() returns null instead, for parts that also render on their own (the sidebar, the header and the toggle use it). The frame's canvas above reads it to show the mode and to fold the sidebar.
| Value | Type | What it is |
|---|---|---|
mode | 'desktop' | 'tablet' | 'mobile' | From the window width: 1024px and up, 768 to 1023px, below 768px. |
collapsed | boolean | The sidebar is the icon rail. Always false on phones. |
setCollapsed | (next: boolean) => void | Fold or unfold the rail. |
toggleCollapsed | () => void | Flip it. |
drawerOpen | boolean | The phone drawer is open. |
setDrawerOpen | (next: boolean) => void | Open or close the drawer. |
import { useShell } from '@orbitcommerce/constellation'
function PageHeader() {
const shell = useShell()
return shell.mode === 'mobile' ? null : <Button size="sm">View store</Button>
}Header, breadcrumbs and brand
TopBar takes three slots. start holds Breadcrumbs (or a page title), center the search pill that grows into the command palette, and end the actions: ghost icon buttons 4px apart, the View store pill and the account avatar. ShellBrand is the Orbit logo: placement="header" for the app's logo top left of the sidebar, or the default quiet signature (with an optional meta such as a version) for a footer. Both turn to the mark alone in the rail.
Guidelines
- One AppShell per app, at the root. Pages render inside Canvas; they never draw their own frame.
- Keep the sidebar and header on the ground: no backgrounds, cards or shadows of their own. The canvas is the only raised surface.
- Group items under short sentence-case section titles, with a Phosphor icon for each top-level item: regular at rest, duotone when active.
- Name items for the place, in a word or two: Orders, Gift cards, Media library.
- Give every icon-only header button a label and a tooltip, and keep the header to a handful of actions.
Accessibility
- Landmarks: the sidebar is an
asidenamed bylabel(default "Main") with anavinside (default "Primary"), the top bar is aheader, and the canvas is themainelement unless you changeas. - Sections are groups named by their titles. The active item carries aria-current="page", and its count is left out of its name, with badgeLabel attached as a description.
- A parent without a page is a button with aria-expanded. A parent with a page has a separate toggle named by toggleLabel, with aria-expanded and aria-controls. Closed groups are inert, so their links leave the tab order.
- Items are ordinary links and buttons in tab order. In the rail their names stay in place for screen readers and show as tooltips for everyone else.
- SidebarToggle has aria-expanded and aria-keyshortcuts="[". The phone menu button has aria-expanded, and Escape closes the drawer.
- Breadcrumbs are a nav named "Breadcrumb" holding an ordered list, with the current page marked and the separators hidden.
- Focus rings are teal and 2px on every item, toggle, crumb and the store button.
- With reduced motion the columns and the drawer change at once, the pills jump instead of gliding, groups open without the slide, and counts neither bump nor ping.
Props
AppShell
| Prop | Type | Default | Description |
|---|---|---|---|
sidebarRequired | ReactNode | None | The Sidebar. |
headerRequired | ReactNode | None | The TopBar. |
childrenRequired | ReactNode | None | The page, usually a Canvas. |
collapsed | boolean | None | Controlled icon rail. Leave undefined to let the shell manage it. |
defaultCollapsed | boolean | false | Starting state when uncontrolled. |
onCollapsedChange | (collapsed: boolean) => void | None | Called when the rail is folded or unfolded. |
drawerOpen | boolean | None | Controlled phone drawer. |
onDrawerOpenChange | (open: boolean) => void | None | Called when the drawer opens or closes. |
aside | ReactNode | None | Content docked beside the canvas, e.g. an assistant panel. |
Canvas
| Prop | Type | Default | Description |
|---|---|---|---|
as | 'main' | 'div' | 'section' | 'main' | Element for the scroll container. The ref points at it. |
scrollClassName | string | None | Classes for the inner scroll container. |
top | ReactNode | None | Content pinned above the scroll area, e.g. banners. |
Sidebar
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | 'Main' | Accessible name for the sidebar landmark. |
collapsed | boolean | None | Force the icon rail regardless of the shell, e.g. when rendered on its own. |
childrenRequired | ReactNode | None | Header, nav and footer. |
SidebarNav and SidebarSection
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | 'Primary' | SidebarNav: accessible name for the nav landmark. |
title | ReactNode | None | SidebarSection: quiet heading, sentence case. A tick in the rail. |
SidebarItem
| Prop | Type | Default | Description |
|---|---|---|---|
labelRequired | ReactNode | None | The item name. Also the rail tooltip. |
icon | ReactNode | None | Icon at rest (18px). |
activeIcon | ReactNode | None | Icon while active, e.g. the duotone weight. Falls back to icon. |
active | boolean | false | The current page: the active pill settles here. |
current | boolean | false | A child page is active: reads as current without the pill. |
badge | ReactNode | None | A small count on the right. A rising number bumps and pings. |
badgeLabel | string | None | What the count means, for screen readers, e.g. "12 orders to fulfil". |
link | ReactElement | None | The element that navigates, e.g. <NavLink to="/orders" />. Without it the row is a button. |
onSelect | (event: MouseEvent<HTMLElement>) => void | None | Called when the row is pressed. |
children | ReactNode | None | Nested items, one level. |
open | boolean | None | Controlled open state for nested items. |
defaultOpen | boolean | false | Starting open state when uncontrolled. |
onOpenChange | (open: boolean) => void | None | Called when the group opens or closes. |
toggleLabel | string | 'Show more' | Name of the expand toggle on a parent with its own page. |
itemId | string | None | Stable id for the hover pill. Generated when omitted. |
TopBar
| Prop | Type | Default | Description |
|---|---|---|---|
start | ReactNode | None | Left: breadcrumbs or the page title. |
center | ReactNode | None | Centre: usually the SearchTrigger. Hidden on phones. |
end | ReactNode | None | Right: actions, notifications, account. |
menuLabel | string | 'Open navigation' | Name of the menu button shown on phones. |
Breadcrumbs
| Prop | Type | Default | Description |
|---|---|---|---|
itemsRequired | BreadcrumbItem[] | None | { label, link?, key? }. The last item is the current page. |
label | string | 'Breadcrumb' | Accessible name for the nav. |
SidebarToggle
| Prop | Type | Default | Description |
|---|---|---|---|
collapseLabel | string | 'Collapse sidebar' | Name while expanded. |
expandLabel | string | 'Expand sidebar' | Name while folded. Renders nothing on phones or outside a shell. |
StoreButton and ShellBrand
| Prop | Type | Default | Description |
|---|---|---|---|
nameRequired | ReactNode | None | StoreButton: the store name. |
detail | ReactNode | None | StoreButton: a second line, e.g. the store's domain. |
avatar | ReactNode | None | StoreButton: logo or initials, e.g. <Avatar shape="square" />. |
placement | 'header' | 'footer' | 'footer' | ShellBrand: header is the app logo top left at full strength; footer is a quiet signature. |
meta | ReactNode | None | ShellBrand: shown beside the logo when expanded, e.g. a version. |