Skip to content
design
Components

App shell

The frame of every screen, holding the header, the navigation, the page, a side panel and a footer, laid out by its own width.

On this page
At anchor in Morgat at three widths. Choose a width: the navigation turns from a tab bar into a rail and then a panel, and the side panel moves from under the alerts to beside them. On a desktop, drag a splitter or press Enter on it; the widths stay in this browser.
Code
import { AlertListRow } from "@bosunkit/components/alert-list-row";
import { AlertZone, type AlertZoneAlert } from "@bosunkit/components/alert-zone";
import { AppShell } from "@bosunkit/components/app-shell";
import { DescriptionList } from "@bosunkit/components/description-list";
import { HeaderBar } from "@bosunkit/components/header-bar";
import { NavList, type NavGroup } from "@bosunkit/components/nav-list";
import { SectionHeading } from "@bosunkit/components/section-heading";
import { SegmentedRadioGroup, type SegmentedRadioOption } from "@bosunkit/components/segmented-radio-group";
import { StatusStrip, type SensorSummary } from "@bosunkit/components/status-strip";
import { NavigationBar, type NavigationBarItem } from "@bosunkit/components/navigation-bar";
import { TelemetryStrip } from "@bosunkit/components/telemetry-strip";
import {
  Anchor,
  BookOpen,
  Cloud,
  House,
  LayoutDashboard,
  Map as MapIcon,
  Menu,
  MessageSquare,
  Settings,
  TriangleAlert,
  Users,
  Waves,
} from "lucide-react";
import { useState, type CSSProperties, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "The app shell at anchor in Morgat, at a phone's, a tablet's and a desktop's width: the header bar with the " +
  "anchor drag alarm, the navigation as a tab bar, a rail or a panel, the alerts, the side panel under or beside " +
  "them, and splitters on a desktop whose widths the browser keeps.";

export const readsClock: boolean = true;

const WIDTHS: readonly SegmentedRadioOption[] = [
  { value: "24em", label: "Phone" },
  { value: "45em", label: "Tablet" },
  { value: "80em", label: "Desktop" },
];

const GROUPS: readonly NavGroup[] = [
  {
    id: "underway",
    label: "Underway",
    items: [
      { href: "#dashboard", label: "Dashboard", icon: <LayoutDashboard /> },
      {
        href: "#situation",
        label: "Situation",
        icon: <TriangleAlert />,
        count: { value: 1, label: "1 unacknowledged alert", priority: "alarm" },
      },
      { href: "#chart", label: "Chart", icon: <MapIcon />, count: { value: 6, label: "6 targets tracked" } },
      { href: "#anchor", label: "Anchor", icon: <Anchor /> },
    ],
  },
  {
    id: "passage",
    label: "Passage",
    items: [
      { href: "#weather", label: "Weather", icon: <Cloud /> },
      { href: "#tides", label: "Tides", icon: <Waves /> },
      { href: "#log", label: "Log", icon: <BookOpen /> },
    ],
  },
  {
    id: "aboard",
    label: "Aboard",
    items: [
      { href: "#chat", label: "Chat", icon: <MessageSquare /> },
      { href: "#crew", label: "Crew", icon: <Users />, count: { value: 4, label: "4 people aboard" } },
      { href: "#settings", label: "Settings", icon: <Settings /> },
    ],
  },
];

const SENSORS: readonly SensorSummary[] = ["gps", "compass", "depth", "wind"].map((name: string): SensorSummary => ({
  name,
  integrity: "fresh",
  ageSeconds: 1,
}));

function ignore(): void {
  // The example changes nothing on these taps.
}

/**
 * The shell at the chosen width in a frame that scrolls sideways. On a declared screen (a device preview, or the hero
 * narrowed) the screen is the width: the switch hides, the shell takes the screen's width, and the frame scrolls
 * nothing, so the tab bar stays at the foot of the screen.
 */
const FRAME_STYLES: string = `
  .app-shell-showcase__frame {
    width: 100%;
    overflow-x: auto;
    border: 1px solid var(--border);
  }
  .app-shell-showcase__screen {
    width: var(--app-shell-showcase-width);
  }
  @container bk-screen (min-width: 0px) {
    .app-shell-showcase__width {
      display: none;
    }
    .app-shell-showcase__frame {
      overflow-x: visible;
    }
    .app-shell-showcase__screen {
      width: auto;
    }
  }
`;

export default function Showcase(): ReactElement {
  const [width, setWidth]: [string, Dispatch<SetStateAction<string>>] = useState<string>("45em");
  const [menuOpen, setMenuOpen]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(false);
  const [openedAt]: [number, Dispatch<SetStateAction<number>>] = useState<number>((): number => Date.now());
  const [acknowledged, setAcknowledged]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(false);

  function acknowledge(): Promise<void> {
    // Stands in for the board: the alarm reads acknowledged once the next snapshot confirms it.
    return new Promise<void>((resolve: () => void): void => {
      setTimeout((): void => {
        setAcknowledged(true);
        resolve();
      }, 1200);
    });
  }

  const alarm: AlertZoneAlert | null = acknowledged
    ? null
    : {
        id: "drag",
        title: "Anchor dragging",
        priority: "alarm",
        raisedAt: openedAt - 2 * 60_000,
      };

  const tabs: readonly NavigationBarItem[] = [
    { id: "home", label: "Home", icon: <House />, href: "#dashboard" },
    { id: "chart", label: "Chart", icon: <MapIcon />, href: "#chart" },
    {
      id: "alerts",
      label: "Alerts",
      icon: <TriangleAlert />,
      href: "#situation",
      ...(acknowledged ? {} : { count: { value: 1, label: "1 unacknowledged alert", priority: "alarm" as const } }),
    },
    { id: "chat", label: "Chat", icon: <MessageSquare />, href: "#chat" },
    {
      id: "more",
      label: "More",
      icon: <Menu />,
      expanded: menuOpen,
      onSelect: (): void => {
        setMenuOpen(!menuOpen);
      },
    },
  ];

  return (
    <div style={{ display: "grid", gap: "var(--space-md)", width: "100%" }}>
      <style href="bosunkit-example-app-shell" precedence="default">
        {FRAME_STYLES}
      </style>
      <div className="app-shell-showcase__width">
        <SegmentedRadioGroup label="Screen width" options={WIDTHS} value={width} onValueChange={setWidth} />
      </div>
      <div className="app-shell-showcase__frame">
        <div className="app-shell-showcase__screen" style={{ "--app-shell-showcase-width": width } as CSSProperties}>
          <AppShell
            layoutStorageKey="bosun-design-app-shell"
            menuOpen={menuOpen}
            onMenuOpenChange={setMenuOpen}
            header={
              <HeaderBar
                title="Situation"
                alertZone={
                  <AlertZone
                    count={acknowledged ? 0 : 1}
                    alert={alarm}
                    acknowledgeAction={acknowledge}
                    boardHref="#situation"
                  />
                }
                statusStrip={
                  <StatusStrip
                    connection="live"
                    lastEventAt={openedAt}
                    activity="anchored"
                    sensors={SENSORS}
                    risk={null}
                    doNotDisturb={{ enabled: false, missed: 0, onToggle: ignore }}
                    nightSuggested={false}
                    onDismissSuggestion={ignore}
                    unacknowledgedAlertIds={acknowledged ? [] : ["drag"]}
                  />
                }
              />
            }
            navigation={<NavList groups={GROUPS} currentHref="#situation" />}
            navigationBar={<NavigationBar items={tabs} current="alerts" />}
            main={
              <div style={{ display: "grid", gap: "var(--space-sm)" }}>
                <SectionHeading level={2} count={3}>
                  Active alerts
                </SectionHeading>
                <ul style={{ display: "grid", gap: "var(--space-xs)", margin: 0, padding: 0, listStyle: "none" }}>
                  <AlertListRow
                    priority="alarm"
                    title="Anchor dragging"
                    description="Althea is 41 m from the drop point, outside the 35 m swing radius."
                    source="Anchor watch"
                    time="02:16"
                    timeReference="UTC"
                    state={acknowledged ? "acknowledged" : "unacknowledged"}
                    acknowledgeAction={acknowledge}
                  />
                  <AlertListRow
                    priority="warning"
                    title="Gusts above 25 kn"
                    source="Wind"
                    time="01:52"
                    timeReference="UTC"
                    state="acknowledged"
                  />
                  <AlertListRow
                    priority="caution"
                    title="House bank at 48 %"
                    source="Battery monitor"
                    time="00:40"
                    timeReference="UTC"
                    state="unacknowledged"
                  />
                </ul>
              </div>
            }
            aside={
              <div style={{ display: "grid", gap: "var(--space-sm)" }}>
                <SectionHeading level={2}>Anchor</SectionHeading>
                <DescriptionList
                  items={[
                    { term: "Dropped", value: "21:40 UTC" },
                    { term: "Depth", value: "6.4 m" },
                    { term: "Rode out", value: "35 m" },
                    { term: "Swing radius", value: "35 m" },
                  ]}
                />
              </div>
            }
            footer={
              <TelemetryStrip
                groups={[
                  { label: "House", value: "12.4 V", status: "warn" },
                  { label: "Wind", value: "22 kn" },
                  { label: "Depth", value: "6.4 m" },
                ]}
              />
            }
          />
        </div>
      </div>
    </div>
  );
}

The app shell is the frame every screen sits in: the header bar at the top, the navigation at the start, the page in the middle, a side panel at the end and a footer along the foot. It holds no data; each region is a slot that takes a kit part, a HeaderBar, a NavList, a NavigationBar or the page's own content. ui/web keeps the routing: the links in the navigation and the navigation bar go through the provider's link component, and the caller says which page is current.

Each region is a landmark. The header is the page's banner, the navigation a navigation landmark named "All pages", the page main, the side panel a complementary region named by asideLabel, and the footer contentinfo. A skip link leads the tab order, out of sight until it takes focus, and moves focus to the page.

Layout

The shell's root is an inline-size container, so it lays out by its own width, not the window's. The widths are the kit's breakpoints in em, measured against the shell's font size, so a larger size class lays out as a narrower screen in which its larger targets fit.

Shell widthNavigationSide panel
Phone, up to 40emThe tab bar at the foot, or a menu button in the header that opens the navigation above the pageUnder the page
Tablet, up to 68.75emAn icon rail, 4.5em wideUnder the page
Desktop, wider than 68.75emA panel with labels, 14em wide, with a splitterBeside the page, 20em wide, with a splitter

The root is also the page's bk-screen, the container the kit's narrow forms query: the status strip drops its captions, the alert zone takes its own row and buttons grow to a thumb's target when the shell is phone-wide, wherever it is drawn. The header stays at the top of the window on the alert layer, so a dialog or the dimmer never covers the alert zone; stickyHeader={false} lets a shell scroll with a document, as on this page. With fillHeight the shell fills a container of fixed height (the window, or a device's screen) and the page scrolls inside it under the header; it is then the bk-screen on both axes, so a component's @container bk-screen (max-height: …) query measures it.

01The alert zone in every layoutThe header bar heads the shell at every width and stays on the alert layer, so an alert reaches the crew on every page and over every dialog.
02Width moves the regions, size class sizes themThe shell rearranges by the room it has. How far away the crew stands sets the size class, which scales the type and targets the layout is measured in.
03The crew set the widthsOn a desktop the navigation and the side panel resize and fold from their splitters, by pointer or by key, and the browser keeps what the crew set.

Examples

A phone with a tab bar

Skip to main content

Tides

High water at Morgat 14:12 UTC, 6.1 m.

At a phone's width the navigation gives way to the tab bar at the foot; its More opens every page above the content, and following a link closes it.
Code
import { AppShell } from "@bosunkit/components/app-shell";
import { NavList, type NavGroup } from "@bosunkit/components/nav-list";
import { NavigationBar } from "@bosunkit/components/navigation-bar";
import { BookOpen, Cloud, House, Map as MapIcon, Menu, MessageSquare, Waves } from "lucide-react";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "At a phone's width the navigation gives way to the tab bar at the foot; its More opens every page above the " +
  "content, and following a link closes it.";

const GROUPS: readonly NavGroup[] = [
  {
    id: "passage",
    label: "Passage",
    items: [
      { href: "#weather", label: "Weather", icon: <Cloud /> },
      { href: "#tides", label: "Tides", icon: <Waves /> },
      { href: "#log", label: "Log", icon: <BookOpen /> },
    ],
  },
];

export default function PhoneWithANavigationBar(): ReactElement {
  const [menuOpen, setMenuOpen]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(false);
  return (
    <div style={{ width: "24em", maxWidth: "100%", border: "1px solid var(--border)" }}>
      <AppShell
        stickyHeader={false}
        menuOpen={menuOpen}
        onMenuOpenChange={setMenuOpen}
        header={<p style={{ margin: 0, padding: "var(--space-sm) var(--space-lg)" }}>Tides</p>}
        navigation={<NavList groups={GROUPS} currentHref="#tides" />}
        main={<p style={{ margin: 0 }}>High water at Morgat 14:12 UTC, 6.1 m.</p>}
        navigationBar={
          <NavigationBar
            current="more"
            items={[
              { id: "home", label: "Home", icon: <House />, href: "#dashboard" },
              { id: "chart", label: "Chart", icon: <MapIcon />, href: "#chart" },
              { id: "chat", label: "Chat", icon: <MessageSquare />, href: "#chat" },
              {
                id: "more",
                label: "More",
                icon: <Menu />,
                expanded: menuOpen,
                onSelect: (): void => {
                  setMenuOpen(!menuOpen);
                },
              },
            ]}
          />
        }
      />
    </div>
  );
}

A phone with a menu

Skip to main content

Anchor

Holding: 18 m from the drop point, inside the 35 m swing radius.

Without a tab bar, a phone-wide shell puts a menu button at the start of the header; it opens the navigation above the page, and Escape closes it.
Code
import { AppShell } from "@bosunkit/components/app-shell";
import { NavList, type NavGroup } from "@bosunkit/components/nav-list";
import { Anchor, Cloud, LayoutDashboard } from "lucide-react";
import type { ReactElement } from "react";

export const description: string =
  "Without a tab bar, a phone-wide shell puts a menu button at the start of the header; it opens the navigation " +
  "above the page, and Escape closes it.";

const GROUPS: readonly NavGroup[] = [
  {
    id: "pages",
    items: [
      { href: "#dashboard", label: "Dashboard", icon: <LayoutDashboard /> },
      { href: "#anchor", label: "Anchor", icon: <Anchor /> },
      { href: "#weather", label: "Weather", icon: <Cloud /> },
    ],
  },
];

export default function PhoneWithAMenu(): ReactElement {
  return (
    <div style={{ width: "24em", maxWidth: "100%", border: "1px solid var(--border)" }}>
      <AppShell
        stickyHeader={false}
        header={<p style={{ margin: 0, padding: "var(--space-sm) var(--space-md)" }}>Anchor</p>}
        navigation={<NavList groups={GROUPS} currentHref="#anchor" />}
        main={<p style={{ margin: 0 }}>Holding: 18 m from the drop point, inside the 35 m swing radius.</p>}
      />
    </div>
  );
}

A tablet

Skip to main content

Weather

Southwest 18 kn, gusts 24 kn, backing south by the evening.

At a tablet's width the navigation is an icon rail, each link named in a tooltip, and the side panel folds under the page.
Code
import { AppShell } from "@bosunkit/components/app-shell";
import { NavList, type NavGroup } from "@bosunkit/components/nav-list";
import { Anchor, Cloud, LayoutDashboard, TriangleAlert } from "lucide-react";
import type { ReactElement } from "react";

export const description: string =
  "At a tablet's width the navigation is an icon rail, each link named in a tooltip, and the side panel folds " +
  "under the page.";

const GROUPS: readonly NavGroup[] = [
  {
    id: "underway",
    label: "Underway",
    items: [
      { href: "#dashboard", label: "Dashboard", icon: <LayoutDashboard /> },
      {
        href: "#situation",
        label: "Situation",
        icon: <TriangleAlert />,
        count: { value: 2, label: "2 unacknowledged alerts", priority: "warning" },
      },
      { href: "#anchor", label: "Anchor", icon: <Anchor /> },
      { href: "#weather", label: "Weather", icon: <Cloud /> },
    ],
  },
];

export default function Tablet(): ReactElement {
  return (
    <div style={{ width: "45em", maxWidth: "100%", overflowX: "auto", border: "1px solid var(--border)" }}>
      <div style={{ width: "45em" }}>
        <AppShell
          stickyHeader={false}
          header={<p style={{ margin: 0, padding: "var(--space-sm) var(--space-lg)" }}>Weather</p>}
          navigation={<NavList groups={GROUPS} currentHref="#weather" />}
          main={<p style={{ margin: 0 }}>Southwest 18 kn, gusts 24 kn, backing south by the evening.</p>}
          aside={<p style={{ margin: 0 }}>Barometer 1011 hPa, falling 3.1 hPa in 3 hr.</p>}
          asideLabel="Barometer"
        />
      </div>
    </div>
  );
}

Resizing on a desktop

The button above the navigation folds the panel to its rail and expands it to the last panel width. It stays in the same navigation region and shares state with the splitter. It is available with resizable={false}; setting collapsible={false} removes it. Fixed rail layouts and phone menus use their own presentation without this toggle.

Each splitter is a window splitter: a separator named for its region, read as the region's width in em. The arrow keys step it 1em and Shift 4em, Home and End go to the narrowest and the widest, and Enter folds the navigation to the icon rail and shuts the side panel. A drag follows the pointer, mouse, finger or pen, and a fold changes the columns at once: animating them would lay the whole app out again on every frame. The widths are the shell's own, or the caller's (navigationWidth, asideWidth), or kept per browser under layoutStorageKey, read on the first render so the shell opens at the kept widths.

Skip to main content

Log

02:00 UTC. At anchor in Morgat, holding. Wind southwest 18 kn.

On a desktop the navigation and the side panel resize from the splitters on their edges, by drag or by the arrow keys, and Enter folds them; the widths stay in this browser under layoutStorageKey.
Code
import { AppShell } from "@bosunkit/components/app-shell";
import { NavList, type NavGroup } from "@bosunkit/components/nav-list";
import { BookOpen, Cloud, LayoutDashboard, Waves } from "lucide-react";
import type { ReactElement } from "react";

export const description: string =
  "On a desktop the navigation and the side panel resize from the splitters on their edges, by drag or by the " +
  "arrow keys, and Enter folds them; the widths stay in this browser under layoutStorageKey.";

const GROUPS: readonly NavGroup[] = [
  {
    id: "passage",
    label: "Passage",
    items: [
      { href: "#dashboard", label: "Dashboard", icon: <LayoutDashboard /> },
      { href: "#weather", label: "Weather", icon: <Cloud /> },
      { href: "#tides", label: "Tides", icon: <Waves /> },
      { href: "#log", label: "Log", icon: <BookOpen /> },
    ],
  },
];

export default function Resizing(): ReactElement {
  return (
    <div style={{ width: "100%", overflowX: "auto", border: "1px solid var(--border)" }}>
      <div style={{ width: "80em" }}>
        <AppShell
          stickyHeader={false}
          layoutStorageKey="bosun-design-app-shell-resizing"
          header={<p style={{ margin: 0, padding: "var(--space-sm) var(--space-lg)" }}>Log</p>}
          navigation={<NavList groups={GROUPS} currentHref="#log" />}
          main={<p style={{ margin: 0 }}>02:00 UTC. At anchor in Morgat, holding. Wind southwest 18 kn.</p>}
          aside={<p style={{ margin: 0 }}>Next entry due at 03:00 UTC.</p>}
          asideLabel="Log schedule"
          navigationMinWidth={10}
          asideMaxWidth={30}
        />
      </div>
    </div>
  );
}

A console

mode="console" frames a display that stays on one view, such as conning or the night watch display. The header keeps the alert zone on the alert layer, and the shell draws no navigation, menu button or tab bar, even when they are given. splitPresets names the ways the screen divides between the page and the side panel ("Chart", "Rose and chart"); where the panel stands beside the page, a segmented control at the header's end offers them, named "Layout". Choosing one sets the panel's width within its bounds, as its splitter would, or shuts it for a width of 0, and the split in use is the one checked. A width the crew drag to themselves checks none. A console usually fills the screen, so it takes fillHeight.

Skip to main content

Conning

ChartRose and chartInstruments

Chart: Morgat anchorage, own vessel at anchor, swing circle 45 m.

A conning console: the header keeps the alert zone, there is no navigation, and the named splits at the header's end divide the screen between the chart and the instruments.
Code
import { AppShell, type AppShellSplitPreset } from "@bosunkit/components/app-shell";
import type { ReactElement } from "react";

export const description: string =
  "A conning console: the header keeps the alert zone, there is no navigation, and the named splits at the " +
  "header's end divide the screen between the chart and the instruments.";

const SPLITS: readonly AppShellSplitPreset[] = [
  { id: "chart", label: "Chart", asideWidth: 0 },
  { id: "rose", label: "Rose and chart", asideWidth: 24 },
  { id: "instruments", label: "Instruments", asideWidth: 40 },
];

export default function Console(): ReactElement {
  return (
    <div style={{ width: "100%", overflowX: "auto", border: "1px solid var(--border)" }}>
      <div style={{ width: "80em", height: "24em" }}>
        <AppShell
          mode="console"
          fillHeight
          splitPresets={SPLITS}
          defaultAsideWidth={24}
          asideMaxWidth={40}
          header={<p style={{ margin: 0, padding: "var(--space-sm) var(--space-lg)" }}>Conning</p>}
          main={<p style={{ margin: 0 }}>Chart: Morgat anchorage, own vessel at anchor, swing circle 45 m.</p>}
          aside={<p style={{ margin: 0 }}>Heading 223°T, wind southwest 18 kn, depth 8.4 m.</p>}
          asideLabel="Instruments"
        />
      </div>
    </div>
  );
}

Props

PropTypeDefaultDescription
modeAppShellMode | undefined"app"What the shell frames: an application screen, or a console with no navigation; app by default.
splitPresetsreadonly AppShellSplitPreset[] | undefined–Named splits between the page and the side panel, offered in the header where the panel stands beside the page: on a desktop, a shell wider than 68.75em. Choosing one sets the panel's width as a drag on its splitter would.
splitPresetsLabelstring | undefined–The split buttons' group name; "Layout" in the screen's language when unset.
headerReactNode | undefined–The bar at the top (HeaderBar), in the page's banner landmark.
navigationReactNode | undefined–The navigation (NavList), in a navigation landmark: a panel on a desktop, a rail on a tablet.
navigationFooterReactNode | undefined–Identity or other persistent controls below the navigation's independently scrolling destinations.
navigationLabelstring | undefined–The navigation landmark's name; "All pages" in the screen's language when unset, so it differs from the tab bar's "Main navigation" while the phone's menu shows both.
navigationModeAppShellNavigationMode | undefined"auto"How the navigation column shows from tablet width up; auto by default.
mainrequiredReactNode–The page, in the main landmark, which the skip link moves to.
asideReactNode | undefined–The side panel, in a complementary landmark: beside the page on a wide screen, under it on a narrow one.
asideLabelstring | undefined–The side panel's name; "Side panel" in the screen's language when unset.
footerReactNode | undefined–The foot of the screen, such as a telemetry strip, in the contentinfo landmark.
navigationBarReactNode | undefined–The phone's bottom navigation (NavigationBar), shown at phone width in place of the navigation column. Without one, a phone gets a menu button at the start of the header that opens the navigation over the page.
menuOpenboolean | undefined–Whether the navigation menu is open, for a caller that holds it (a tab bar's More opens it).
defaultMenuOpenboolean | undefinedfalseWhether the menu starts open, when the shell holds it itself.
onMenuOpenChange((open: boolean) => void) | undefined–Called with the menu's new state when the crew open or close it, or follow a link in it.
stickyHeaderboolean | undefinedtrueWhether the header stays at the top of the window while the page scrolls, on the alert layer, so a dialog or the dimmer never covers the alert zone; on by default. The navigation then stays beside the page under it.
fillHeightboolean | undefinedfalseWhether the shell fills the height of its container, which must have a fixed height (the window's, or a device screen's), rather than growing with its page. The page then scrolls inside the shell under the header, and the shell is the bk-screen on both axes, so a component's @container bk-screen (max-height: …) query measures it. Off by default: the shell grows with its page, and a height query inside it measures the nearest fixed-height bk-screen around it.
resizableboolean | undefinedtrueWhether splitters on the navigation's and the side panel's edges resize them, where they stand beside the page: on a desktop, a shell wider than 68.75em. On by default; a phone or a tablet never shows them.
collapsibleboolean | undefinedtrueWhether the navigation button and Enter on a splitter fold their region: navigation to a rail, aside shut. On by default.
navigationWidthnumber | undefined–The navigation panel's width in em, for a caller that holds it.
defaultNavigationWidthnumber | undefined14The navigation panel's width in em at first, when the shell holds it; 14.
onNavigationWidthChange((width: number) => void) | undefined–Called with the navigation's new width in em as the crew drag or step its splitter.
navigationMinWidthnumber | undefined11The narrowest the navigation panel can be dragged, in em; 11.
navigationMaxWidthnumber | undefined24The widest, in em; 24.
navigationCollapsedboolean | undefined–Whether the navigation is folded to the icon rail, for a caller that holds it.
defaultNavigationCollapsedboolean | undefinedfalseWhether it starts folded, when the shell holds it.
onNavigationCollapsedChange((collapsed: boolean) => void) | undefined–Called when the crew fold or unfold the navigation from its button or splitter.
asideWidthnumber | undefined–The side panel's width in em, for a caller that holds it.
defaultAsideWidthnumber | undefined20The side panel's width in em at first, when the shell holds it; 20.
onAsideWidthChange((width: number) => void) | undefined–Called with the side panel's new width in em as the crew drag or step its splitter.
asideMinWidthnumber | undefined14The narrowest the side panel can be dragged, in em; 14.
asideMaxWidthnumber | undefined36The widest, in em; 36.
asideCollapsedboolean | undefined–Whether the side panel is shut, for a caller that holds it.
defaultAsideCollapsedboolean | undefinedfalseWhether it starts shut, when the shell holds it.
onAsideCollapsedChange((collapsed: boolean) => void) | undefined–Called when the crew shut or open the side panel from its splitter.
layoutStorageKeystring | undefined–A storage key under which the shell keeps the widths and folds the crew set, per browser, through the kit's persisted store. The shell reads it on its first render in a browser, so it lays out at the kept sizes at once.
classNamestring | undefined–Classes added to the root's own.
testIdstring | undefined–Becomes data-testid on the root; the menu button is ${testId}--menu.
refRef<HTMLDivElement> | undefined–The root element.
Do
Skip to main content

Header bar with the alert zone

The page.

Put the header bar in the header slot, so the alert zone heads the page at every width and over every dialog.
Don't
Skip to main content

Alert zone, then the page.

Don't put the alert zone in the page. It scrolls away with the content, and a dialog covers it.

Persistent navigation identity

navigationFooter accepts a NavUser or other persistent controls. It stays below the independently scrolling links and follows the column between panel and rail. Its account menu inherits the presentation scope and returns focus to the trigger. The navigation's available height is measured below the sticky header.

See the account and submenu composition for a live workspace with a primary column, team submenu, local tabs and a user card. The caller supplies account actions; the shell owns only layout and navigation visibility.