Pattern: app frame
Who uses it: everyone, on every screen — the persistent chrome the whole suite lives inside. How often: it never leaves the screen. What "done" looks like: any module is one click away, and search, notifications and the user menu are in the same place on every screen of every module. The anatomy is the modern consensus across enterprise suites (Fiori's shell bar, Dynamics' area-plus-sitemap, Lightning, Carbon's UI shell): a suite-level top bar, a module-level sidebar, and a main region that belongs entirely to the page. Not for a single-task dedicated device — a handheld scanner runs RF full-screen with no frame at all; and not an iframe — the frame is the layout every page renders inside, not a container that embeds pages.
The screen
The page — including its own breadcrumb and title. Nothing here is the frame's business.
The waffle button and the user menu are native
[popover] menus — open them: Escape closes, click-away closes,
focus returns to the trigger, none of it our code. The switcher menu is
app launch in miniature; its
last entry goes to the full launcher.
Frame-owned vs page-owned — the boundary that keeps a suite coherent
Every design system studied draws the same line (GOV.UK made it a published rule: suite-wide tools and service-level tools must not blur). What is true on every screen lives in the frame; everything else belongs to the page:
| Element | Owner | Why |
|---|---|---|
| Brand, module switcher, global search, notifications, user menu | Frame (top bar) | true everywhere; Atlassian's redesign stripped the top bar to exactly this set |
| Module navigation | Frame (sidebar) | contextual to the module; swaps whole on module change — never mixed into the top bar |
| Breadcrumb, page title, page actions, content | Page | the frame must not need to know what any page is about |
| Which modules exist, their order, role visibility | Server | authorization data, rendered into the frame — never framework opinion |
Markup
<!-- The frame is ONE navbar + ONE sidebar around every screen.
Top bar = suite-level only (true on every screen); sidebar = the current
module's nav; everything else — breadcrumbs, titles, content — belongs
to the page. The switcher and user menu are native [popover] menus:
Escape, click-away and focus return are platform behavior, not ours. -->
<div class="bo-app-shell">
<header class="bo-navbar bo-app-shell__header">
<a class="bo-navbar__brand" href="/">Acme ERP</a>
<!-- Module switcher: the waffle. Reuses the launcher's tile mechanism
at menu size — the menu is app-launch in miniature. -->
<button class="bo-btn bo-btn--ghost" type="button" popovertarget="frame-apps"
aria-label="Switch module">
<span class="bo-icon bo-icon--grid" aria-hidden="true"></span>
</button>
<div class="bo-dropdown__menu" id="frame-apps" popover>
<a class="bo-dropdown__item" href="/purchasing"><span class="bo-icon bo-icon--cart" aria-hidden="true"></span> Purchasing</a>
<a class="bo-dropdown__item" href="/warehouse"><span class="bo-icon bo-icon--box" aria-hidden="true"></span> Warehouse</a>
<a class="bo-dropdown__item" href="/reports"><span class="bo-icon bo-icon--chart" aria-hidden="true"></span> Reports</a>
<a class="bo-dropdown__item" href="/home">All applications…</a>
</div>
<span class="bo-navbar__spacer"></span>
<input class="bo-input" type="search" aria-label="Search everything"
placeholder="Search…" style="max-inline-size: 14rem" />
<!-- Sync-state slot: badge + glyph SHAPE + text (two channels), the
live region announces changes. States: ● Online / ↻ Syncing /
⏸ 3 queued / ✕ Sync error. YOUR app sets them; the frame is only
the one place they always appear. -->
<span class="bo-badge bo-badge--success"><span aria-hidden="true">●</span> Online</span>
<span aria-live="polite" class="bo-visually-hidden">Connection: online</span>
<a class="bo-btn bo-btn--ghost" href="/notifications">
Alerts <span class="bo-badge bo-badge--warning">3</span>
</a>
<button class="bo-btn bo-btn--ghost" type="button" popovertarget="frame-user">
<span class="bo-avatar" aria-hidden="true">JK</span> J. Kim
</button>
<div class="bo-dropdown__menu" id="frame-user" popover>
<a class="bo-dropdown__item" href="/profile">Profile</a>
<a class="bo-dropdown__item" href="/signout">Sign out</a>
</div>
</header>
<nav class="bo-sidebar-nav bo-app-shell__sidebar" aria-label="Purchasing">
…the CURRENT module's nav (swaps whole when the module changes)…
</nav>
<main class="bo-app-shell__main"> …the page, including its own breadcrumb… </main>
</div> Anatomy
- Shell — .bo-app-shell: header + sidebar + main; the sidebar collapses to an icon rail in the shell's narrow container state, labels kept in the accessibility tree.
- Top bar — navbar: brand, then the suite-level controls.
- Module switcher — a ghost button opening a dropdown of module links; the full grid lives on app launch, which the menu's last entry opens.
- Global search — a search input; wire it to the command bar for type-to-jump.
- Sync state — a
badge whose glyph SHAPE and
text both change per state, with an
aria-liveannouncement — the full contract is the offline concept page. - Notifications — a link with a badge count into the notification center.
- User menu — avatar + dropdown.
- Module sidebar —
sidebar nav with
aria-current="page"on the active entry; itsaria-labelnames the module.
Data contract
| Request | Returns / on failure |
|---|---|
| GET (any page) | the frame renders server-side around every page — modules and sidebar entries already filtered by the user's role; a module the user cannot open is absent, not disabled |
| GET /notifications/count | a fragment swapped into the badge once available; the bar renders WITHOUT it. On failure the badge simply never appears — a silent degrade, same contract as the launcher's tile counts |
| 4xx on a module link | a full navigation to an error page — the frame stays; only main is the casualty |
States
| State | What ships |
|---|---|
| Current module | the sidebar's active entry carries aria-current="page" + the visible marker — two channels |
| Narrow shell (container state) | the sidebar collapses to its icon rail; labels stay in the accessibility tree. A bottom-nav tier is deliberately NOT shipped — re-opens on a real consumer need |
| No permission for a module | absent from switcher and sidebar both — a disabled module teaches nothing and leaks structure |
| Count unavailable | no badge, no error — the bar never fails loudly over a number |
the whole frame disappears (.bo-navbar and the sidebar are print-hidden); paper gets the page, not the chrome | |
| No JS | the switcher and user menus are native popover — they open, close on Escape/click-away and return focus with zero JS; only menu anchoring (initDropdowns) is an enhancement. Search submits as a form; every module link is a real link |
Components used
complexity 2 of 4