Skip to content

Navigation

AppShell

The landmarks of a product — a sidebar column, a sticky topbar, a scrolling main region and an optional status strip — plus the navigation model that fills them.

Examples

The standard shell

The real component at a bounded height: sidebar, topbar, content and status bar are the same frame the Showcase itself runs in. Push the rail on the edge, or the toggle in the sidebar footer, and the column becomes a rail with every label kept as an accessible name; below 1024px the column gives way to the drawer in the topbar. Ctrl/⌘ + B is off here so the demo cannot fight the Showcase's own shell.

The column is on.

Skip to content
Overview
Window1Y
Samples365
Series3
Prepared locallyUpdated 12:04 UTC

Filtering the model

The same tree the sidebar renders, filtered as the reader types. A parent survives when one of its children matches, so a search never hides the way to the page it found — and the function that drives this demo is the one the Showcase sidebar calls.

Filtering keeps the path

The model is data, so everything that reads it is a pure function: filter it, flatten it, or turn it into command entries without keeping a second copy of the routes.

const groups = filterNavigation(navigation, "prices"); // The Datasets parent survives the filter; nothing else does. isNavigationItemActive("/charts/timeseries", "/charts"); // true isNavigationItemActive("/charts-archive", "/charts"); // false — the boundary is a path segment, not a prefix findActiveNavigationItem(groups, pathname); // the most specific ancestor

What the model derives

Every navigable item, depth-first, with a pre-computed lowercase haystack for matching. The command palette does not keep its own copy of the routes; extras are for actions that are not navigation — copy the view, switch the theme, run a job.

  • Overview/application
  • Datasetsaction
  • Prices/application/navigation
  • Flows/application/navigation
  • Reports/application/workspace
  • Settings/application/url-state
  • Billing/application/url-state

6 command entries derived from 7 navigation items.

Routing is injected, not imported

The library never imports a router. Three props say what an href means, which location is current, and what happens when someone follows one — which is the whole integration, for Next, React Router or a plain anchor.

NextReact RouterPlain anchor

Every link in the frames above is a real anchor to a real route. That is the default, not the adapter.

Usage

import { AppShell, AppShellContent, AppShellStatusBar, AppShellTopbar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupLabel, SidebarHeader, SidebarItem, SidebarMenu, SidebarToggle, } from "@luc/ui/app-shell";
<AppShell
  pathname={pathname}
  renderLink={(props) => <Link {...props} />}
  sidebar={
    <SidebarContent ariaLabel="Sections">
      <SidebarGroup>
        <SidebarGroupLabel>Research</SidebarGroupLabel>
        <SidebarMenu>
          <SidebarItem label="Datasets" href="/datasets" />
        </SidebarMenu>
      </SidebarGroup>
    </SidebarContent>
  }
  topbar={<AppShellTopbar>…</AppShellTopbar>}
>
  <AppShellContent>…</AppShellContent>
</AppShell>

The shell owns the landmarks, the skip link and the responsive switch; the sidebar's contents are yours. `renderLink`, `pathname` and `onNavigate` are the only router-shaped props — the library never imports a router.

Exports

Read from the published TypeScript API, so a prop that is not listed here does not exist and a default that is listed is the one the compiler sees.

One import publishes four modules: the shell, the sidebar parts, the breadcrumbs and the page header. All four tables are below — the shell's two components and `SidebarItem` are open, and the rest of the sub-surface folds away behind a disclosure, because a page that documents five modules at once has to keep a reader from scrolling through props they did not come for.

Exported symbols
ExportKindSignature
AppShell22 documented props({ children, sidebar, topbar, statusBar, mobileNav, mobileNavTitle, sidebarColumn, sidebarCollapsed, defaultSidebarCollapsed, onSidebarCollapsedChange, collapsibleSidebar, sidebarPersistKey, sidebarP…
AppShellContent1 documented props({ className, width, ...props }: AppShellContentProps) => import("@types/react").JSX.Element
AppShellMobileNav2 documented props({ children, title }: AppShellMobileNavProps) => import("@types/react").JSX.Element
AppShellMobileTrigger1 documented props({ className }: { className?: string; }) => import("@types/react").JSX.Element
AppShellSidebarexport({ className, children, ...props }: React.ComponentPropsWithRef<"aside">) => import("@types/react").JSX.Element
AppShellStatusBarexport({ className, ...props }: React.ComponentPropsWithRef<"div">) => import("@types/react").JSX.Element
AppShellTopbarexport({ className, children, ...props }: React.ComponentPropsWithRef<"header">) => import("@types/react").JSX.Element
Exported symbols
ExportKindSignature
Sidebarexport({ className, children, ...props }: React.ComponentPropsWithRef<"div">) => import("@types/react").JSX.Element
SidebarContent1 documented props({ className, ariaLabel, ...props }: SidebarContentProps) => import("@types/react").JSX.Element
SidebarFooterexport({ className, ...props }: React.ComponentPropsWithRef<"div">) => import("@types/react").JSX.Element
SidebarGroup5 documented props({ label, children, collapsible, defaultOpen, badge, className, ...props }: SidebarGroupProps) => import("@types/react").JSX.Element
SidebarGroupLabelexport({ className, children, ...props }: React.ComponentPropsWithRef<"p">) => import("@types/react").JSX.Element
SidebarHeaderexport({ className, ...props }: React.ComponentPropsWithRef<"div">) => import("@types/react").JSX.Element
SidebarItem9 documented props({ label, href, icon, badge, active, disabled, children, defaultOpen, onSelect, className, ...props }: SidebarItemProps) => import("@types/react").JSX.Element
SidebarMenuexport({ className, ...props }: React.ComponentPropsWithRef<"ul">) => import("@types/react").JSX.Element
SidebarProvider12 documented props({ children, collapsed: collapsedProp, defaultCollapsed, onCollapsedChange, collapsible, persistKey, persistVersion, storage, shortcut, mobileOpen: mobileOpenProp, defaultMobileOpen, onMobileOpenChan…
SidebarRail2 documented props({ className, expandLabel, collapseLabel, ...props }: SidebarRailProps) => import("@types/react").JSX.Element | null
SidebarSubmenu6 documented props({ label, href, icon, badge, active, defaultOpen, className, children, ...props }: SidebarSubmenuProps) => import("@types/react").JSX.Element
SidebarToggle6 documented props({ className, label, ...props }: Omit<React.ComponentPropsWithRef<typeof IconButton>, "icon" | "label"> & { label?: string; }) => import("@types/react").JSX.Element | null
useSidebarexport() => SidebarContextValue
Exported symbols
ExportKindSignature
Breadcrumbexport({ className, ...props }: React.ComponentPropsWithRef<"nav">) => import("@types/react").JSX.Element
BreadcrumbItemexport({ className, ...props }: React.ComponentPropsWithRef<"li">) => import("@types/react").JSX.Element
BreadcrumbLink2 documented props({ className, href, children, ...props }: BreadcrumbLinkProps) => import("@types/react").JSX.Element
BreadcrumbListexport({ className, ...props }: React.ComponentPropsWithRef<"ol">) => import("@types/react").JSX.Element
BreadcrumbPageexport({ className, ...props }: React.ComponentPropsWithRef<"span">) => import("@types/react").JSX.Element
Breadcrumbs4 documented props({ items, maxItems, className, trailing }: BreadcrumbsProps) => import("@types/react").JSX.Element
BreadcrumbSeparatorexport({ className, children, ...props }: React.ComponentPropsWithRef<"li">) => import("@types/react").JSX.Element
Exported symbols
ExportKindSignature
PageHeader9 documented props({ eyebrow, title, description, meta, actions, controls, tabs, density, sticky, className, children, ...props }: PageHeaderProps) => import("@types/react").JSX.Element
PageHeaderMeta1 documented props({ marker, className, children, ...props }: PageHeaderMetaProps) => import("@types/react").JSX.Element
Exported symbols
ExportKindSignature
NavigationLink4 documented props({ href, onNavigate, onClick, children, ...props }: NavigationLinkExtendedProps & { onNavigate?: (href: string) => void; }) => import("@types/react").JSX.Element
NavigationProvider4 documented props({ renderLink, pathname, onNavigate, children, }: NavigationProviderProps) => import("@types/react").JSX.Element
useNavigationexport() => NavigationContextValue

AppShell props

Type: AppShellProps

Values the compiler reads from the declaration.

AppShell props
PropTypeDefaultNotes
childrenReactNode—
className?string | undefined—
collapsibleSidebar?boolean | undefined—`false` pins the sidebar open. Default `true`.
contentId?string | undefined—Target of the skip link.
defaultSidebarCollapsed?boolean | undefined—
density?"compact" | "comfortable" | undefined—
height?"fill" | "viewport" | undefined—`viewport` (default) fills the document and lets the page scroll. `fill` sizes to a bounded parent instead — use it to embed a shell in a dashboard, a preview, or a test.
mobileNav?ReactNode—Contents of the mobile navigation sheet. Defaults to `sidebar`. Either one being present gives the shell a drawer. Below `lg` the drawer *is* the navigation, so a shell with no de…
mobileNavTitle?string | undefined—
onNavigate?((href: string) => void) | undefined—Called on any in-shell navigation (close the drawer, prefetch, track).
onSidebarCollapsedChange?((collapsed: boolean) => void) | undefined—
pathname?string | null | undefined—The current location, used for active state.
renderLink?((props: NavigationLinkProps) => ReactNode) | undefined—How to render internal links. Injected by the product's router adapter.
sidebar?ReactNode—Sidebar contents, rendered inside the shell's `aside` and `nav`. Omitting this is a supported shell shape, not a misconfiguration: a product landing page has no sidebar, and the s…
sidebarCollapsed?boolean | undefined—Controlled / uncontrolled sidebar collapse.
sidebarColumn?boolean | undefined—Whether the sidebar renders as a column beside the content. `true` (default) is the application frame. `false` is the landing-page shape: the same contents render in the off-canva…
sidebarPersistKey?string | undefined—Persist the collapsed state under this key.
sidebarPersistVersion?number | undefined—
sidebarShortcut?boolean | undefined—Enable the Ctrl/Cmd + B sidebar toggle. Default `true`.
statusBar?ReactNode—Optional status strip below the main region.
storage?KeyValueStorage | undefined—
topbar?ReactNode—Sticky topbar. Compose it from `AppShellTopbar` or your own markup.

SidebarItem props

Type: SidebarItemProps

Values the compiler reads from the declaration.

SidebarItem props
PropTypeDefaultNotes
active?boolean | undefined—Override active detection, e.g. for a route alias.
badge?ReactNode—
children?ReactNode—Nested items. One level; a deeper tree wants a different pattern.
defaultOpen?boolean | undefined—
disabled?boolean | undefined—
href?string | undefined—
icon?ReactNode—
labelstring—
onSelect?(() => void) | undefined—Fires after a click — the AppShell uses it to close the mobile drawer.

Breadcrumbs props

Type: BreadcrumbsProps

Values the compiler reads from the declaration.

Breadcrumbs props
PropTypeDefaultNotes
className?string | undefined—
itemsreadonly BreadcrumbTrailItem[]—
maxItems?number | false | undefined—Collapse the middle of a long trail behind an ellipsis once it exceeds this many crumbs. The first and last are always visible. `false` disables it.
trailing?ReactNode—Rendered after the trail (a copy-link button, a status chip).

PageHeader props

Type: PageHeaderProps

Values the compiler reads from the declaration.

PageHeader props
PropTypeDefaultNotes
actions?ReactNode—Primary actions, right-aligned on wide screens.
controls?ReactNode—Secondary controls that belong with the header, e.g. a view toggle.
density?"comfortable" | "dense" | undefined—`dense` tightens the vertical rhythm for analytical pages. It changes spacing, never type size or touch targets.
description?ReactNode—
eyebrow?ReactNode—Small context line above the title, e.g. a section or a dataset kind.
meta?ReactNode—Inline metadata row under the description: status, timestamp, counts.
sticky?boolean | undefined—Keep the header pinned while the page scrolls.
tabs?ReactNode—Sub-navigation: a `Tabs`/`SegmentedControl` row pinned under the header.
titleReactNode—

NavigationProvider props

Type: NavigationProviderProps

Values the compiler reads from the declaration.

NavigationProvider props
PropTypeDefaultNotes
childrenReactNode—
onNavigate?((href: string) => void) | undefined—
pathname?string | null | undefined—
renderLink?((props: NavigationLinkProps) => ReactNode) | undefined—

AppShellContent props

Type: AppShellContentProps

Values the compiler reads from the declaration.

AppShellContent props
PropTypeDefaultNotes
width?"content" | "prose" | "bleed" | undefined—`bleed` removes the page gutters for a full-width research workspace.
AppShellMobileNav props2 props

AppShellMobileNav props

Type: AppShellMobileNavProps

Values the compiler reads from the declaration.

AppShellMobileNav props
PropTypeDefaultNotes
childrenReactNode—
title?string | undefined—
AppShellMobileTrigger props1 props

AppShellMobileTrigger props

Type: { className?: string; }

Values the compiler reads from the declaration.

AppShellMobileTrigger props
PropTypeDefaultNotes
className?string | undefined—
SidebarContent props1 props

SidebarContent props

Type: SidebarContentProps

Values the compiler reads from the declaration.

SidebarContent props
PropTypeDefaultNotes
ariaLabel?string | undefined—Accessible name for the navigation landmark.
SidebarGroup props5 props

SidebarGroup props

Type: SidebarGroupProps

Values the compiler reads from the declaration.

SidebarGroup props
PropTypeDefaultNotes
badge?ReactNode—Trailing marker beside the heading, e.g. a count.
childrenReactNode—
collapsible?boolean | undefined—Render the heading as a disclosure, so the group's rows can be folded away. The heading is a `<button>` and never a link: it has no destination, and a control that looks like navi…
defaultOpen?boolean | undefined—Open on first render. The caller derives this from the current route.
label?string | undefined—Section heading. Omit for an ungrouped run of items.
SidebarProvider props12 props

SidebarProvider props

Type: SidebarProviderProps

Values the compiler reads from the declaration.

SidebarProvider props
PropTypeDefaultNotes
childrenReactNode—
collapsed?boolean | undefined—Controlled collapsed state.
collapsible?boolean | undefined—`false` means the sidebar cannot collapse. Default `true`.
defaultCollapsed?boolean | undefined—
defaultMobileOpen?boolean | undefined—
mobileOpen?boolean | undefined—
onCollapsedChange?((collapsed: boolean) => void) | undefined—
onMobileOpenChange?((open: boolean) => void) | undefined—
persistKey?string | undefined—Persist the collapsed state locally under this key.
persistVersion?number | undefined—
shortcut?boolean | undefined—Enable the `Ctrl/⌘ + B` toggle. Default `true`.
storage?KeyValueStorage | undefined—
SidebarRail props2 props

SidebarRail props

Type: SidebarRailProps

Values the compiler reads from the declaration.

SidebarRail props
PropTypeDefaultNotes
collapseLabel?string | undefined—
expandLabel?string | undefined—Accessible names for the two states.
SidebarSubmenu props6 props

SidebarSubmenu props

Type: SidebarSubmenuProps

Values the compiler reads from the declaration.

SidebarSubmenu props
PropTypeDefaultNotes
active?boolean | undefined—
badge?ReactNode—
defaultOpen?boolean | undefined—
href?string | undefined—
icon?ReactNode—
labelstring—
SidebarToggle props6 props

SidebarToggle props

Type: Omit<IconButtonProps, "label" | "icon"> & { label?: string; }

Values the compiler reads from the declaration.

SidebarToggle props
PropTypeDefaultNotes
label?string | undefined—
loading?boolean | undefined—
showTooltip?boolean | undefined—Show the label as a real tooltip on hover. Use `@luc/ui` Tooltip for rich content.
size?"sm" | "md" | "lg" | null | undefined—
tactile?boolean | undefined—Press ink and pointer light. Off for a static or print surface.
variant?"danger" | "accent" | "ghost" | "solid" | null | undefined—
BreadcrumbLink props2 props

Type: BreadcrumbLinkProps

Values the compiler reads from the declaration.

BreadcrumbLink props
PropTypeDefaultNotes
childrenReactNode—
hrefstring—
PageHeaderMeta props1 props

PageHeaderMeta props

Type: PageHeaderMetaProps

Values the compiler reads from the declaration.

PageHeaderMeta props
PropTypeDefaultNotes
marker?ReactNode—A leading marker: a status dot, an icon. Decorative.
NavigationLink props4 props

Type: NavigationLinkExtendedProps & { onNavigate?: (href: string) => void; }

Values the compiler reads from the declaration.

NavigationLink props
PropTypeDefaultNotes
childrenReactNode—
hrefstring—
onNavigate?((href: string) => void) | undefined—
ref?Ref<HTMLAnchorElement> | undefined—

Guidelines

Do

  • Reach for `AppShell` when a product has a sidebar, a topbar and a scrolling main region: the landmark structure, the skip link and the off-canvas drawer come with it.
  • Pass `height="fill"` when the shell lives inside a pane, a preview or a test rather than filling the document.
  • Keep navigation *data* in a `NavigationGroup[]` and render it into the sidebar, the drawer and the palette from that one model, so a page cannot exist in one surface and be missing from another.

Don't

  • Do not write page-local shell CSS: the sidebar width, the topbar height and the status strip are tokens, and a page that overrides them is a second shell.
  • Do not import a router into a component to make a link work — supply `renderLink` from the product, which is what keeps the library renderer-agnostic.
  • Do not give the topbar a translucent background: text over a scrolled surface is the pattern the design system forbids, and the hairline underneath is what separates it.

Accessibility

The shell renders the `aside`, `header` and `main` landmarks, a skip link to the content region, and one named `nav` (`SidebarContent ariaLabel`) per sidebar — so a screen-reader user can jump straight to the navigation and to the content. Collapsing to the rail keeps every label in the DOM as the accessible name and reveals it with the existing tooltip, the rail is a real button outside the collapsed content, and any in-shell navigation closes the mobile drawer through one handler. `Ctrl/⌘ + B` is opt-out with `sidebarShortcut`.

Luc UI v1.0AppShelldark theme