App shell

App shell lays out the learner app: sidebar, top bar, main content, an optional panel and a phone tab bar. Use it once per app, around every page.

  • Beta
  • Accessibility
  • Figma Sidebar 125:11450 · Header Nav 292:24031 · Sidebar Items 57:16007
  • packages/ui/src/components/AppShell

Beta until design confirms the code-only parts: the phone tab bar, the toggle in the collapsed rail, the right panel, hover states that Figma lacks, and the phone top bar. Focus uses the native outline (C10).

Preview

A real AppShell in a fixed-height frame, so it doesn't take over this page. Change the frame width to see the layout switch: the shell measures its own width, not the window's. Collapse the sidebar with its toggle or the control.

Skip to main content

Library

Page content renders in <main>. Tab from the top of the frame to find the skip link.

Cell biology basics
Photosynthesis in 10 minutes
Intro to statistics
The French Revolution
tsx
import {
  AppShell, Sidebar, SidebarSection, SidebarItem, SidebarFooter, TopBar, TopBarSearch,
  BottomNav, BottomNavItem, Button, DropdownMenuItem,
} from '@nuton/ui'

<AppShell
  sidebar={
    <Sidebar collapsed={collapsed} onCollapsedChange={setCollapsed} logo={<Logo />}
      footer={
        <SidebarFooter name="Ada Obi" email="ada@example.com" plan="PRO">
          <DropdownMenuItem>Settings</DropdownMenuItem>
          <DropdownMenuItem>Sign out</DropdownMenuItem>
        </SidebarFooter>
      }
    >
      <SidebarSection>
        <SidebarItem intent="primary" icon={<RiAddLine />} shortcut="⌘N" onClick={startNew}>Start New</SidebarItem>
        <SidebarItem href="/library" icon={<RiBookOpenLine />} active>Library</SidebarItem>
        <SidebarItem href="/spaces" icon={<RiStackLine />}>Spaces</SidebarItem>
      </SidebarSection>
      <SidebarSection label="Recents">
        <SidebarItem kind="entry" href="/lessons/cell-biology">Cell biology basics</SidebarItem>
      </SidebarSection>
    </Sidebar>
  }
  topBar={
    <TopBar
      start={<TopBarSearch onClick={openSearch} />}
      end={<><Button appearance="stroke">Help</Button></>}
    />
  }
  bottomNav={
    <BottomNav>
      <BottomNavItem icon={<RiAddLine />} onClick={startNew}>New</BottomNavItem>
      <BottomNavItem href="/library" icon={<RiBookOpenLine />} active>Library</BottomNavItem>
      <BottomNavItem href="/spaces" icon={<RiStackLine />}>Spaces</BottomNavItem>
      <BottomNavItem href="/profile" icon={<RiUser3Line />}>Profile</BottomNavItem>
    </BottomNav>
  }
>
  <LibraryPage />
</AppShell>

Installation

Availability

@nuton/ui and @nuton/tokens are private workspace packages. They aren't published to a registry yet (D4: registry deferred; D12: distribution waits on the provenance review). Inside the Nuton monorepo, add them as workspace dependencies. The commands below apply once the packages are published.

1. Add the packages

bash
pnpm add @nuton/ui @nuton/tokens @remixicon/react

Peer dependencies: react and react-dom 18 or later. Navigation icons come from @remixicon/react (Remix Icon is Nuton's icon set, D1).

2. Load tokens and component styles once

tsx
// app/layout.tsx (or your app's entry file)
import '@nuton/tokens/tokens.css' // --nu-* design tokens
import '@nuton/ui/styles.css'     // component styles (uses the tokens)

The shell uses CSS container queries for its phone layout, supported by every current browser. The sidebar uses Tooltip and the profile menu uses DropdownMenu; both ship in the same stylesheet. Apps supply the logo: @nuton/ui ships no brand assets (D12).

Usage

tsx
import {
  AppShell, Sidebar, SidebarSection, SidebarItem, SidebarFooter, TopBar, TopBarSearch,
  BottomNav, BottomNavItem, Button, DropdownMenuItem,
} from '@nuton/ui'
import { RiAddLine, RiBookOpenLine, RiStackLine, RiUser3Line } from '@remixicon/react'

export function LearnerLayout({ children }: { children: React.ReactNode }) {
  const [collapsed, setCollapsed] = useState(false)
  return (
    <AppShell
      sidebar={
        <Sidebar
          collapsed={collapsed}
          onCollapsedChange={setCollapsed}
          logo={<a href="/" aria-label="Nuton home"><Logo /></a>}
          footer={
            <SidebarFooter name={user.name} email={user.email} plan="PRO">
              <DropdownMenuItem onSelect={openSettings}>Settings</DropdownMenuItem>
              <DropdownMenuItem onSelect={signOut}>Sign out</DropdownMenuItem>
            </SidebarFooter>
          }
        >
          <SidebarSection>
            <SidebarItem intent="primary" icon={<RiAddLine />} shortcut="⌘N" aria-keyshortcuts="Meta+N" onClick={startNew}>
              Start New
            </SidebarItem>
            <SidebarItem as={Link} href="/library" icon={<RiBookOpenLine />} active={path === '/library'}>Library</SidebarItem>
            <SidebarItem as={Link} href="/spaces" icon={<RiStackLine />} active={path === '/spaces'}>Spaces</SidebarItem>
          </SidebarSection>
          <SidebarSection label="Recents">
            {recents.map((l) => (
              <SidebarItem key={l.id} kind="entry" as={Link} href={l.href} active={path === l.href}>{l.title}</SidebarItem>
            ))}
          </SidebarSection>
        </Sidebar>
      }
      topBar={<TopBar start={<TopBarSearch onClick={openSearch} />} end={<Button appearance="stroke">Help</Button>} />}
      bottomNav={
        <BottomNav>
          <BottomNavItem icon={<RiAddLine />} onClick={startNew}>New</BottomNavItem>
          <BottomNavItem as={Link} href="/library" icon={<RiBookOpenLine />} active={path === '/library'}>Library</BottomNavItem>
          <BottomNavItem as={Link} href="/spaces" icon={<RiStackLine />} active={path === '/spaces'}>Spaces</BottomNavItem>
          <BottomNavItem as={Link} href="/profile" icon={<RiUser3Line />} active={path === '/profile'}>Profile</BottomNavItem>
        </BottomNav>
      }
    >
      {children}
    </AppShell>
  )
}
  • Render one AppShell around every page. It adds the skip link, the <main> landmark and the layout switch.
  • Mark the current page with active on its SidebarItem and BottomNavItem. It sets aria-current="page".
  • Give every SidebarItem an icon: the collapsed rail shows only icons, with the label as a tooltip.
  • Pass your router's link with as (it must forward its ref). Items without href render buttons.
  • Store the collapsed state (for example in local storage) and pass it back with collapsed.

Variants and sizes

The shell has two desktop sidebar states and a phone layout. Each example below is a scaled-down shell in a 540px frame.

Expanded sidebar

Skip to main content

Library

Page content renders in <main>. Tab from the top of the frame to find the skip link.

Cell biology basics
Photosynthesis in 10 minutes
Intro to statistics
The French Revolution

Collapsed rail

Labelled sections (Pinned, Recents) leave the rail, as in Figma. Labels stay as accessible names and appear as tooltips on hover and focus.

Skip to main content

Library

Page content renders in <main>. Tab from the top of the frame to find the skip link.

Cell biology basics
Photosynthesis in 10 minutes
Intro to statistics
The French Revolution

Phone layout

Below 768px of the shell's own width, the sidebar hides and the tab bar appears. Force a layout with layout="desktop" or "phone".

Skip to main content

Library

Page content renders in <main>. Tab from the top of the frame to find the skip link.

Cell biology basics
Photosynthesis in 10 minutes
Intro to statistics
The French Revolution

Phone drawer

Set phoneNav="drawer" to give phones a menu instead of a tab bar, the way chat apps do. The sidebar leaves the page and slides in from the start edge, always in its full form, with a close button, and pushes the page aside by its width (the page moves, it is not resized; they travel together). A light scrim over the part of the page still in view closes it. Put an AppShellMenuButton in the top bar's start slot: it renders nothing outside the phone layout, so the same top bar works on desktop. Choosing a page, pressing Escape or tapping the scrim closes the drawer and returns focus to the menu button; the page behind it is inert while it is open. It follows the reading direction (it opens from the right in right-to-left). The drawer is positioned inside the shell, so use it with scroll="contained", and leave out bottomNav.

Skip to main content

Library

Page content renders in <main>. Tab from the top of the frame to find the skip link.

Cell biology basics
Photosynthesis in 10 minutes
Intro to statistics
The French Revolution

Sizes

PartSizeSource
Sidebar267 wide expanded, 72 rail; padding 24 / 16; 1px right borderFigma Sidebar 125:11450
Sidebar groupsGap 20 (header, divider, sections); 40 before the footer; label to list 4Figma Sidebar
Toggle28 × 28, radius 8, Bg/Weak-50Figma "Chat Custom button"
Nav item36 tall, padding 8 / 12, radius 8, icon 20, gap 8, Label/Small; rail 36 × 36; gap 12 betweenSidebar Items 57:16007
Start New36 tall, padding 8, radius 10, 20px icon circle; rail 36px pillStart New Button 125:2065
Entry (Pinned, Recents)40 tall, padding 8, radius 8, Paragraph/Small; gap 8 betweenNavbar Dropdown Items 125:31747
Profile cardPadding 12 / 0, gap 12, Avatar 40, chevron 24Sidebar Footer 125:11675
Top bar100 tall, padding 32 at the sides, 1px bottom border; phone 64 tall, padding 16 (code-only)Header Nav 292:24031
Top bar icon button40 × 40, padding 10, radius 10, icon 20Topbar Item Button 57:16121
Tab bar (code-only)Items at least 56 tall, icon 24, Label/X Small, 1px top border, safe-area paddingDerived from tokens
Right panel (code-only)360 wide; stacks under the content below 1024--nu-app-shell-panel-width

Values from the component CSS, which follows the Figma geometry read on 2026-10-14.

States and interactions

PartDefaultHoverActive (current)
Nav itemText/Sub-600, Icon/Sub-600Bg/Weak-50Bg/Weak-50, text and icon State/Primary/Base
Start NewState/Primary/Base text, icon in a Primary/Lighter circleBg State/Primary/LighterSame as hover
EntryText/Strong-950, Icon/Soft-400Bg/Weak-50, action showsBg/Soft-200
Top bar icon buttonIcon/Sub-600Bg/Weak-50Bg/Weak-50, icon State/Primary/Base (while aria-expanded or aria-pressed is true)
Tab bar item (code-only)Text/Sub-600Text/Strong-950State/Primary/Base

Gaps in Figma (pending a decision)

  • Figma hides the toggle in the rail, leaving no way to expand it. Code shows it under the logo (code-only).
  • No hover state for the toggle, the rail Start New, the profile card or the search trigger. Code adds them (code-only).
  • No focus state anywhere in the shell. Code keeps the native outline, inset on rows (C10).
  • Section labels and the search text use Text/Soft-400 and Text/Secondary in Figma (below 4.5:1). Code uses Text/Sub-600 (D7).
  • The expanded sidebar frame uses ghost buttons (Strong-950 text) for Library and Spaces; code uses the Sidebar Items component (Sub-600 text).
  • Pinned entries sit 4px apart in Figma and Recents 8px. Code uses 8px for both.

Composition examples

Course page with a panel

Course pages add a Share action to the top bar and a right panel for lesson tools. The panel is an <aside> named by labels.panel.

Skip to main content

Library

Page content renders in <main>. Tab from the top of the frame to find the skip link.

Cell biology basics
Photosynthesis in 10 minutes
Intro to statistics
The French Revolution
Lesson tools and notes go here.

Top bar on its own

The top bar has a start slot (search), an optional middle slot (a mode toggle) and an end slot (actions). Icon-only actions use TopBarButton, which takes a required label.

Inside another page

Previews like the ones on this page sit inside the docs' own <main>. Pass landmarks={false} so the shell doesn't nest a second main, and scroll="contained" with --nu-app-shell-height to fix its height.

API reference

AppShell props

AppShell props
PropTypeDefaultDescription
childrenReactNode—Page content, rendered in <main>.
sidebarReactNode—Usually a Sidebar. Hidden in the phone layout.
topBarReactNode—Usually a TopBar. Sticks to the top.
bottomNavReactNode—Usually a BottomNav. Shown only in the phone layout.
panelReactNode—Right panel in an <aside>. Stacks under the content below 1024px.
layout'auto' | 'desktop' | 'phone''auto'auto switches at 768px of the shell’s own width (container query).
phoneNav'tabs' | 'drawer''tabs'What the phone layout does with the sidebar. tabs: it hides and bottomNav takes over. drawer: it slides in from an AppShellMenuButton and pushes the content aside; bottomNav is not shown. Use with scroll="contained".
closeOnSelectbooleantrueWhether choosing a link in the drawer closes it. Set false when choosing replaces the whole shell (a router that remounts it per page): the new shell then takes the drawer over, so it does not close twice.
scroll'document' | 'contained''document'document: the page scrolls. contained: the shell is --nu-app-shell-height tall and its content column scrolls.
landmarksbooleantruefalse renders main as a <div> and the panel as a named region, for shells inside another page's main.
mainIdstringautoId of main, the skip link target.
mainPropsHTMLAttributes<HTMLElement>—Extra attributes for main.
labels{ skipLink?: string; panel?: string; menu?: string }'Skip to main content', 'Details', 'Menu'Override the English strings.
refRef<HTMLDivElement>—Forwarded to the root.
…restHTMLAttributes<HTMLDivElement>—Any other attribute, on the root.
Sidebar props
PropTypeDefaultDescription
childrenReactNode—SidebarSections, rendered in the <nav>.
collapsedboolean—Collapsed rail (controlled).
defaultCollapsedbooleanfalseInitial state when uncontrolled.
onCollapsedChange(collapsed: boolean) => void—Called by the toggle.
logoReactNode—Your logo (36 × 36). Name a linked logo, e.g. aria-label="Nuton home".
footerReactNode—Usually a SidebarFooter.
showTogglebooleantrueShow the collapse/expand button. In the collapsed rail the button is hidden and the logo reopens the sidebar (Figma); the logo link is taken out of the page while collapsed.
labels{ nav?: string; toggle?: string; close?: string }'Main', 'Sidebar', 'Close menu'Names of the nav landmark, the toggle and the drawer’s close button.
ref, …restHTMLAttributes<HTMLDivElement>—On the root. Its id is the toggle’s aria-controls target.
AppShellMenuButton props
PropTypeDefaultDescription
labelstringlabels.menuAccessible name. The button shows only the menu icon.
ref, …restButtonHTMLAttributes<HTMLButtonElement>—On the button. It carries aria-expanded and aria-controls for the drawer, and renders nothing when the shell is not in the drawer layout.

SidebarSection props

SidebarSection props
PropTypeDefaultDescription
childrenReactNode—SidebarItems, each wrapped in an <li>.
labelReactNode—Visible label that names the list (Pinned, Recents).
hideWhenCollapsedbooleantrue with a labelRemove the section from the rail.
ref, …restHTMLAttributes<HTMLDivElement>—On the section wrapper.

SidebarItem props

SidebarItem props
PropTypeDefaultDescription
childrenrequiredReactNode—The label. In the rail it is visually hidden (still the name) and shown as a tooltip.
iconReactNode—Leading icon. Needed in the rail.
activeIconReactNode—Icon while active (e.g. the filled variant).
hrefstring—Renders an <a>; without it, a <button type="button">.
asElementType—Router link component. It must forward its ref.
activebooleanfalseCurrent page: aria-current and the Active style.
current'page' | 'location' | 'step' | 'true''page'aria-current value when active.
kind'nav' | 'entry''nav'nav: 36px Sidebar Items. entry: 40px Navbar Dropdown Items for Pinned and Recents.
intent'default' | 'primary''default'primary: the Start New action.
shortcutReactNode—Shortcut hint (Kbd), hidden from screen readers. Add aria-keyshortcuts and wire the key.
badgeReactNode—Element after the label. Hidden in the rail.
actionReactNode—A separate control at the end of the row, outside the link. Shown on hover and focus.
classNamestring—On the row wrapper .nu-sidebar-item.
ref, …restAnchorHTMLAttributes & { type?, disabled? }—On the link or button (onClick, aria-*, target…).
SidebarFooter props
PropTypeDefaultDescription
namerequiredstring—Signed-in person. Also the avatar initials.
emailstring—Second line.
avatarSrcstring—Avatar image URL.
avatarColorAvatarColor'gray'Avatar palette for initials.
planReactNode—Plan badge text, e.g. PRO. In the rail it sits on the avatar.
childrenReactNode—DropdownMenu items. When set, the card is a button that opens the menu.
menuPropsDropdownMenuContentProps—Props for the menu surface.
ref, …restHTMLAttributes<HTMLDivElement>—On the footer wrapper.

TopBar, TopBarSearch and TopBarButton props

TopBar props
PropTypeDefaultDescription
startReactNode—Leading slot, usually TopBarSearch.
childrenReactNode—Optional middle slot.
endReactNode—Trailing actions.
ref, …restHTMLAttributes<HTMLElement>—On the <header>.
TopBarSearch shortcutReactNode | null'⌘K'Shortcut hint, hidden from screen readers and on phones. null hides it.
TopBarSearch labels{ search?: string }'Search'Visible text and name.
TopBarSearch …restButtonHTMLAttributes—onClick, aria-keyshortcuts…
TopBarButton labelrequiredstring—Accessible name and tooltip.
TopBarButton iconrequiredReactNode—The icon.
TopBarButton tooltipbooleantrueShow the label as a tooltip.
TopBarButton …restButtonHTMLAttributes—aria-expanded or aria-pressed show the Active style.

BottomNav and BottomNavItem props

BottomNav props
PropTypeDefaultDescription
childrenrequiredReactNode—3–5 BottomNavItems (a development warning flags other counts).
labelstring'Tab bar'Name of the nav landmark.
BottomNavItem childrenrequiredReactNode—Visible label and accessible name.
BottomNavItem icon / activeIconrequiredReactNode—Icon, and the icon while active.
BottomNavItem href / asstring / ElementType—Link target and router link component; without href, a button.
BottomNavItem active / currentboolean / 'page' | …false / 'page'Current destination: aria-current and the active colour.

Styling hooks

Root classes: .nu-app-shell (data-layout, data-scroll), .nu-sidebar (data-collapsed), .nu-sidebar-item (data-kind, data-intent, data-active), .nu-topbar, .nu-bottom-nav. Override points: --nu-app-shell-height, --nu-app-shell-panel-width, --nu-sidebar-width, --nu-sidebar-rail-width, --nu-topbar-height, --nu-topbar-height-phone, --nu-bottom-nav-item-height.

Accessibility

Keyboard

KeyBehaviour
TabFirst stop: the skip link. Then the logo, the toggle, each sidebar item, the profile card, the top bar and the content.
EnterOn the skip link: moves focus to the main content. On an item: follows the link or runs the action.
Enter / SpaceOn the toggle: collapses or expands the sidebar. On the profile card: opens the account menu.
EscCloses a tooltip or the account menu (focus returns to the card).

Screen readers

Landmarks: the sidebar and tab bar are nav landmarks with distinct names (“Main” and “Tab bar”), the top bar is a header, the content is main and the panel a named aside. Only one nav shows at a time. The current item has aria-current="page". Section labels name their lists. The toggle is “Sidebar” with aria-expanded. In the rail, labels stay as names. Their tooltips only repeat the name, so they are not linked as descriptions and the label is read once.

Focus

Native focus outline, inset on rows (C10). The skip link appears on focus at the top left. After it, focus moves to main, which shows no outline.

Contrast and colour

Item text Sub-600 and Strong-950 pass 4.5:1 on white and Weak-50. Section labels and search text use Sub-600 instead of Figma's lighter greys (D7). The current item also changes colour and background; in forced-colours mode it uses Highlight.

Touch and pointer

Rail items are 36 × 36, rows 36–40 tall, the toggle 28 × 28, icon buttons 40 × 40 and tab bar items at least 56 tall: all above 24 × 24. Entry actions show on hover and focus, and always on touch screens.

You must: name a linked logo; give every item an icon and a text label; mark the current page with active; wire any shortcut you show and set aria-keyshortcuts; keep the tab bar to 3–5 items.

Figma mapping

FigmaValuesCode
Sidebar · CollapsedOn · Collapsed Search · Collapsed2 · Collapsed3Sidebar collapsed (On and Collapsed Search = rail; Collapsed2/3 = expanded)
Sidebar Items · StateDefault · Hover · ActiveInteractive states and active
Sidebar Items · CollapsedOff · OnFrom the Sidebar (context)
Start New ButtonDefault · Variant2 (hover)intent="primary" with shortcut
Navbar Dropdown ItemsDefault · hover · selectedkind="entry", active, action
Sidebar Footer · collapsedOn (expanded card) · Off (rail avatar)SidebarFooter (follows the Sidebar; Figma's names are inverted)
Header NavDefault (Help) · second (mode toggle, Share)TopBar slots
Topbar Item Button · StateDefault · Hover · ActiveTopBarButton; Active from aria-expanded/aria-pressed
(none)No phone frameBottomNav, phone top bar, right panel (code-only)

Guidelines and related components

Use App shell for

  • The frame around every signed-in page of the learner app.
  • Switching between the main areas: Library, Spaces, Profile.
  • Quick access to recent and pinned lessons.

Don't use App shell for

  • Navigation inside a page. Use Tabs.
  • Marketing or sign-in pages without the app chrome.
  • Nesting a second shell inside a page.

Do and don't

  • Library
  • Spaces
DoLabel every destination. Text labels work on every screen; in the rail they become tooltips and stay as names.
  • (book icon)
  • (stack icon)
Don'tDon't ship icon-only links without names. The live app's rail links have no names, so screen readers announce only “link”.

New · Library · Spaces · Profile

DoKeep the tab bar to four or five items. Each target stays wide enough to hit, and labels fit.

New · Library · Spaces · Pinned · Recents · Help · Profile

Don'tDon't squeeze in more destinations. Labels truncate and targets shrink. Move the rest to Profile.

Start New ⌘N (wired)

DoShow a shortcut only when it works. Wire ⌘N and ⌘K in the app and set aria-keyshortcuts.

Start New ⌘N (not wired)

Don'tDon't show a hint for a missing shortcut. People try it, nothing happens, and they stop trusting hints.

Common mistakes

  • Forgetting active, so nothing marks the current page.
  • Passing a router link to as that doesn't forward its ref, which breaks the rail tooltip.
  • Putting a menu button inside an item's link. Use action, which renders outside it.
  • Rendering the shell inside another page's <main> without landmarks={false}.