Skip to content
Nizivo UI

AppShell

The page's frame. The Sidebar and the TopBar on screens 640 px and wider, the MobileTopBar and the MobileTabBar below that, around the page's main content, switched by CSS with no script.

Server component, no client JS

Import

import { AppShell } from '@nizivo/react/app-shell';

// Once per app (Getting started), after
// @nizivo/tokens/tokens.css and @nizivo/react/css/base.css and @nizivo/react/css/mobile-tab-bar.css and @nizivo/react/css/mobile-top-bar.css and @nizivo/react/css/sidebar.css and @nizivo/react/css/top-bar.css
import '@nizivo/react/css/app-shell.css';

Static or interactive

Server. AppShell places the parts it is given and ships no client JavaScript of its own. The parts bring their own: the SidebarItems' Tooltip, the MobileTabBar's island and More sheet, and whatever the bars hold. How to choose.

In a client app (a Vite or React Router single-page app, or a 'use client' file) it is an ordinary React component: event handlers, refs and state work as usual. In a client app.

Slots

  • sidebar: a Sidebar, shown from 640 px.
  • topBar: a TopBar, shown from 640 px.
  • mobileTopBar: a MobileTopBar, shown below 640 px.
  • tabBar: a MobileTabBar, fixed to the bottom below 640 px.
  • children: the page, inside the <main> AppShell renders.

The server renders both sets of bars, and CSS shows one: the hidden one is display: none, so it is out of the accessibility tree and its links are not Tab stops. In Next.js, render AppShell in a layout, so it stays mounted while the pages change.

AppShell renders the page's one <main> (with mainId, or a generated id) and a skip link to it, the first Tab stop, shown only while focused ("Skip to content", labels.skip). Don't render another <main> in the page. The page is never wider than layout/content-max, with the grid's margin around it, and below 640 px it leaves room for the tab bar.

Layout

From 640 px, a two-column grid: the Sidebar's own width (the 72 px rail, or 264 px from 1280 px) beside the bar and the page. Below 640 px, one column, with the tab bar fixed to the bottom in the --nz-z-chrome band.

Hooks: --nz-app-shell-bg, --nz-app-shell-main-padding.

Examples

Basic

The shell of Figma's 08 pages: the Sidebar (the icon rail from 640 px, 264 px from 1280 px) and the TopBar over the page; below 640 px the MobileTopBar and the MobileTabBar with its More sheet. The server renders both sets and CSS shows one. AppShell owns <main> and the skip link to it, the first Tab stop. (The example sets a height; in an app the shell fills the viewport.)

Skip to content
Overview
MOverview

Good morning, Maya

import type { JSX, ReactNode } from 'react';
import { Avatar } from '@nizivo/react/avatar';
import { IconButton } from '@nizivo/react/icon-button';
import { MobileTabBar, MobileTabBarMore } from '@nizivo/react/mobile-tab-bar';
import { MobileTopBar } from '@nizivo/react/mobile-top-bar';
import { NavItem } from '@nizivo/react/nav-item';
import { PageHeader } from '@nizivo/react/page-header';
import { SearchField } from '@nizivo/react/search-field';
import { Sidebar, SidebarItem } from '@nizivo/react/sidebar';
import { TopBar } from '@nizivo/react/top-bar';
import { AppShell } from '@nizivo/react/app-shell';

/** Stands in for an `@nizivo/icons` glyph (20 px Lucide strokes, sized by the slot). */
function Glyph({ children }: { children: ReactNode }): JSX.Element {
  return (
    <svg
      viewBox="0 0 24 24"
      width="1em"
      height="1em"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.92}
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      {children}
    </svg>
  );
}

const ICONS = {
  home: (
    <Glyph>
      <path d="M3 10.5 12 3l9 7.5V21H3z" />
      <path d="M9 21v-7h6v7" />
    </Glyph>
  ),
  orders: (
    <Glyph>
      <path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z" />
      <path d="M3 6h18M16 10a4 4 0 0 1-8 0" />
    </Glyph>
  ),
  products: (
    <Glyph>
      <path d="m21 8-9-5-9 5v8l9 5 9-5z" />
      <path d="m3 8 9 5 9-5M12 13v8" />
    </Glyph>
  ),
  customers: (
    <Glyph>
      <circle cx="9" cy="8" r="4" />
      <path d="M2 21a7 7 0 0 1 14 0M16 4a4 4 0 0 1 0 8M22 21a7 7 0 0 0-4-6.3" />
    </Glyph>
  ),
  settings: (
    <Glyph>
      <circle cx="12" cy="12" r="3" />
      <path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9 7 7M17 17l2.1 2.1M4.9 19.1 7 17M17 7l2.1-2.1" />
    </Glyph>
  ),
  search: (
    <Glyph>
      <circle cx="11" cy="11" r="8" />
      <path d="m21 21-4.3-4.3" />
    </Glyph>
  ),
  bell: (
    <Glyph>
      <path d="M10.3 21a1.94 1.94 0 0 0 3.4 0M3.26 15.33A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.67C19.41 13.96 18 12.5 18 8A6 6 0 0 0 6 8c0 4.5-1.41 5.96-2.74 7.33" />
    </Glyph>
  ),
};

export function AppShellExample(): JSX.Element {
  return (
    <AppShell
      style={{ minBlockSize: 480 }}
      sidebar={
        <Sidebar.Root style={{ blockSize: 480 }}>
          <Sidebar.Header mark="M" title="Moss & Kiln" description="mossandkiln.com" />
          <Sidebar.Content>
            <SidebarItem href="/" icon={ICONS.home} current>
              Home
            </SidebarItem>
            <SidebarItem href="/orders" icon={ICONS.orders} count={12}>
              Orders
            </SidebarItem>
            <SidebarItem href="/products" icon={ICONS.products}>
              Products
            </SidebarItem>
            <SidebarItem href="/customers" icon={ICONS.customers}>
              Customers
            </SidebarItem>
          </Sidebar.Content>
          <Sidebar.Footer>
            <SidebarItem href="/settings" icon={ICONS.settings}>
              Settings
            </SidebarItem>
          </Sidebar.Footer>
        </Sidebar.Root>
      }
      topBar={
        <TopBar
          title="Overview"
          search={
            <form method="get" action="/search">
              <SearchField
                name="q"
                placeholder="Search orders, products, customers"
                labels={{ label: 'Search the store' }}
              />
            </form>
          }
          actions={
            <>
              <IconButton icon={ICONS.bell} label="Notifications" variant="secondary" />
              <Avatar name="Maya Lindqvist" />
            </>
          }
        />
      }
      mobileTopBar={
        <MobileTopBar
          mark="M"
          title="Overview"
          actions={
            <>
              <IconButton icon={ICONS.search} label="Search" variant="tertiary" />
              <IconButton icon={ICONS.bell} label="Notifications" variant="tertiary" />
            </>
          }
        />
      }
      tabBar={
        <MobileTabBar.Root>
          <MobileTabBar.Item href="/" icon={ICONS.home} current>
            Home
          </MobileTabBar.Item>
          <MobileTabBar.Item href="/orders" icon={ICONS.orders} count={12}>
            Orders
          </MobileTabBar.Item>
          <MobileTabBar.Item href="/products" icon={ICONS.products}>
            Products
          </MobileTabBar.Item>
          <MobileTabBar.Item href="/customers" icon={ICONS.customers}>
            Customers
          </MobileTabBar.Item>
          <MobileTabBarMore>
            <NavItem href="/settings" icon={ICONS.settings}>
              Settings
            </NavItem>
          </MobileTabBarMore>
        </MobileTabBar.Root>
      }
    >
      <PageHeader title="Good morning, Maya" />
    </AppShell>
  );
}

Props

Server component Renders data-slot="app-shell" with the class nz-app-shell. Props: AppShellProps. Source: src/app-shell/app-shell.tsx.

NameTypeDefaultDescription
childrenrequiredReactNode–

The page, inside <main>.

labelsPartial<AppShellLabels>–
mainIdstring–

The <main> element's id, the skip link's target. Generated when absent.

mobileTopBarReactNode–

A MobileTopBar, shown below 640 px.

sidebarReactNode–

A Sidebar, shown from 640 px.

tabBarReactNode–

A MobileTabBar, pinned to the bottom below 640 px.

topBarReactNode–

A TopBar, shown from 640 px.

  • Also takes every attribute of <div> except children. (Omit<ComponentPropsWithRef<"div">, "children">)

Other exports

AppShellLabels type

import type { AppShellLabels } from '@nizivo/react/app-shell'

Strings this part renders (§16.4).

NameTypeDefaultDescription
skiprequiredstring'Skip to content'

The skip link, the first Tab stop.

AppShellProps type

import type { AppShellProps } from '@nizivo/react/app-shell'

The props of AppShell, listed above.

Data attributes and slots

Style parts by class (.nz-<slot>) and state by data attribute. Attribute meanings come from contracts/vocabulary.json.

SlotRendered byData attributes
app-shell
.nz-app-shell
AppShell
root (no render)
none
app-shell-frame
.nz-app-shell-frame
AppShell
inner element (CSS only)
none
app-shell-main
.nz-app-shell-main
AppShell
inner element (CSS only)
none
app-shell-mobile-top-bar
.nz-app-shell-mobile-top-bar
AppShell
inner element (CSS only)
none
app-shell-sidebar
.nz-app-shell-sidebar
AppShell
inner element (CSS only)
none
app-shell-skip
.nz-app-shell-skip
AppShell
inner element (CSS only); interactive, focus: self
none
app-shell-tab-bar
.nz-app-shell-tab-bar
AppShell
inner element (CSS only)
none
app-shell-top-bar
.nz-app-shell-top-bar
AppShell
inner element (CSS only)
none

CSS hooks

Set a hook on the component's class or any ancestor to restyle it without specificity games; leave it unset for the default shown.

HookDefaultRead as
--nz-app-shell-bg--nz-bg-canvas
  • background on .nz-app-shell
--nz-app-shell-main-paddingvar(--nz-section-gap) var(--nz-grid-margin)
  • padding on .nz-app-shell-main

Layers: nz.components. Classes: .nz-app-shell, .nz-app-shell-frame, .nz-app-shell-main, .nz-app-shell-mobile-top-bar, .nz-app-shell-sidebar, .nz-app-shell-skip, .nz-app-shell-tab-bar, .nz-app-shell-top-bar. Runtime variables: --nz-layout-bottom-inset.

Tokens this stylesheet reads (15)

--nz-bg-canvas, --nz-bg-inverse, --nz-font-label-m-strong, --nz-grid-margin, --nz-layout-content-max, --nz-radius-md, --nz-section-gap, --nz-space-2, --nz-space-3, --nz-space-4, --nz-text-inverse, --nz-text-primary, --nz-tracking-label-m-strong, --nz-z-chrome, --nz-z-overlay

Accessibility

These parts are interactive. Each gets the focus outline and touch-action rule from @nizivo/react/css/base.css (the nz.base layer), so the floor survives a restyle:

  • app-shell-skip: draws the focus outline itself on keyboard focus.

Keyboard

KeysAction
TabFrom the top of the page, the first stop is the skip link, shown while focused. Then the sidebar, the bar and the page, in document order.
EnterOn the skip link, moves focus to the page's main content.

Source: a native skip link to <main tabindex="-1"> (app-shell.tsx, no client code). The browser lane (test/browser/app-shell.spec.tsx) checks the skip link, the parts shown at 1280, 960 and 390 px, and the bottom inset, in Chromium, Firefox and WebKit.

Figma

Node ids from design/component-map.json, which nz gen writes from component.json. The links open the Nizivo Figma library, which may ask you to sign in.

  • Canonical: none
  • Ghost masters: none

On this page