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.
<AppShell
height="fill"
pathname={pathname} // a string, not a router
renderLink={(props) => <Link {...props} />}
onNavigate={closeDrawer}
sidebarCollapsed={collapsed} // controlled, or leave it out
onSidebarCollapsedChange={setCollapsed}
sidebarPersistKey="product:sidebar" // the rail survives a reload
sidebar={
<>
<SidebarHeader>…</SidebarHeader>
<SidebarContent ariaLabel="Sections">
<SidebarGroup>
<SidebarGroupLabel>Overview</SidebarGroupLabel>
<SidebarMenu>
<SidebarItem label="Datasets" href="/datasets" icon={<RiDatabase2Line />}>
<SidebarItem label="Prices" href="/datasets/prices" />
</SidebarItem>
<SidebarItem label="Billing" disabled />
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarToggle />
</SidebarFooter>
</>
}
topbar={
<AppShellTopbar>
<AppShellMobileTrigger />
<Breadcrumbs items={[{ label: "Luc UI", href: "/" }, { label: "Overview" }]} />
</AppShellTopbar>
}
statusBar={<AppShellStatusBar>Updated 12:04 UTC</AppShellStatusBar>}
>
<AppShellContent>…</AppShellContent>
</AppShell>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 ancestorconst groups = filterNavigation(navigation, query);
<SidebarProvider collapsible={false} shortcut={false}>
<SidebarContent ariaLabel="Sections">
{groups.map((group) => (
<SidebarGroup key={group.id}>
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
<SidebarMenu>
{group.items.map((item) => (
<SidebarItem key={item.id} label={item.label} href={item.href} icon={item.icon} />
))}
</SidebarMenu>
</SidebarGroup>
))}
</SidebarContent>
</SidebarProvider>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.
// The palette's list is derived, never hand-written.
const entries = navigationCommandEntries(navigation, [
{ id: "copy-view", label: "Copy view link", action: copyView },
]);
entries.map((entry) => (
<CommandItem key={entry.id} value={entry.haystack} onSelect={() => run(entry)}>
{entry.label}
</CommandItem>
))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.
Every link in the frames above is a real anchor to a real route. That is the default, not the adapter.
<AppShell
pathname={usePathname()}
renderLink={(props) => <Link {...props} />}
onNavigate={(href) => analytics.track("nav", href)}
/>
// With no adapter at all the default renders a real anchor, so middle-click,
// "open in a new tab" and the browser's own link preview keep working.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.
| Export | Kind | Signature |
|---|---|---|
AppShell | 22 documented props | ({ children, sidebar, topbar, statusBar, mobileNav, mobileNavTitle, sidebarColumn, sidebarCollapsed, defaultSidebarCollapsed, onSidebarCollapsedChange, collapsibleSidebar, sidebarPersistKey, sidebarP… |
AppShellContent | 1 documented props | ({ className, width, ...props }: AppShellContentProps) => import("@types/react").JSX.Element |
AppShellMobileNav | 2 documented props | ({ children, title }: AppShellMobileNavProps) => import("@types/react").JSX.Element |
AppShellMobileTrigger | 1 documented props | ({ className }: { className?: string; }) => import("@types/react").JSX.Element |
AppShellSidebar | export | ({ className, children, ...props }: React.ComponentPropsWithRef<"aside">) => import("@types/react").JSX.Element |
AppShellStatusBar | export | ({ className, ...props }: React.ComponentPropsWithRef<"div">) => import("@types/react").JSX.Element |
AppShellTopbar | export | ({ className, children, ...props }: React.ComponentPropsWithRef<"header">) => import("@types/react").JSX.Element |
| Export | Kind | Signature |
|---|---|---|
Sidebar | export | ({ className, children, ...props }: React.ComponentPropsWithRef<"div">) => import("@types/react").JSX.Element |
SidebarContent | 1 documented props | ({ className, ariaLabel, ...props }: SidebarContentProps) => import("@types/react").JSX.Element |
SidebarFooter | export | ({ className, ...props }: React.ComponentPropsWithRef<"div">) => import("@types/react").JSX.Element |
SidebarGroup | 5 documented props | ({ label, children, collapsible, defaultOpen, badge, className, ...props }: SidebarGroupProps) => import("@types/react").JSX.Element |
SidebarGroupLabel | export | ({ className, children, ...props }: React.ComponentPropsWithRef<"p">) => import("@types/react").JSX.Element |
SidebarHeader | export | ({ className, ...props }: React.ComponentPropsWithRef<"div">) => import("@types/react").JSX.Element |
SidebarItem | 9 documented props | ({ label, href, icon, badge, active, disabled, children, defaultOpen, onSelect, className, ...props }: SidebarItemProps) => import("@types/react").JSX.Element |
SidebarMenu | export | ({ className, ...props }: React.ComponentPropsWithRef<"ul">) => import("@types/react").JSX.Element |
SidebarProvider | 12 documented props | ({ children, collapsed: collapsedProp, defaultCollapsed, onCollapsedChange, collapsible, persistKey, persistVersion, storage, shortcut, mobileOpen: mobileOpenProp, defaultMobileOpen, onMobileOpenChan… |
SidebarRail | 2 documented props | ({ className, expandLabel, collapseLabel, ...props }: SidebarRailProps) => import("@types/react").JSX.Element | null |
SidebarSubmenu | 6 documented props | ({ label, href, icon, badge, active, defaultOpen, className, children, ...props }: SidebarSubmenuProps) => import("@types/react").JSX.Element |
SidebarToggle | 6 documented props | ({ className, label, ...props }: Omit<React.ComponentPropsWithRef<typeof IconButton>, "icon" | "label"> & { label?: string; }) => import("@types/react").JSX.Element | null |
useSidebar | export | () => SidebarContextValue |
| Export | Kind | Signature |
|---|---|---|
Breadcrumb | export | ({ className, ...props }: React.ComponentPropsWithRef<"nav">) => import("@types/react").JSX.Element |
BreadcrumbItem | export | ({ className, ...props }: React.ComponentPropsWithRef<"li">) => import("@types/react").JSX.Element |
BreadcrumbLink | 2 documented props | ({ className, href, children, ...props }: BreadcrumbLinkProps) => import("@types/react").JSX.Element |
BreadcrumbList | export | ({ className, ...props }: React.ComponentPropsWithRef<"ol">) => import("@types/react").JSX.Element |
BreadcrumbPage | export | ({ className, ...props }: React.ComponentPropsWithRef<"span">) => import("@types/react").JSX.Element |
Breadcrumbs | 4 documented props | ({ items, maxItems, className, trailing }: BreadcrumbsProps) => import("@types/react").JSX.Element |
BreadcrumbSeparator | export | ({ className, children, ...props }: React.ComponentPropsWithRef<"li">) => import("@types/react").JSX.Element |
| Export | Kind | Signature |
|---|---|---|
PageHeader | 9 documented props | ({ eyebrow, title, description, meta, actions, controls, tabs, density, sticky, className, children, ...props }: PageHeaderProps) => import("@types/react").JSX.Element |
PageHeaderMeta | 1 documented props | ({ marker, className, children, ...props }: PageHeaderMetaProps) => import("@types/react").JSX.Element |
| Export | Kind | Signature |
|---|---|---|
NavigationLink | 4 documented props | ({ href, onNavigate, onClick, children, ...props }: NavigationLinkExtendedProps & { onNavigate?: (href: string) => void; }) => import("@types/react").JSX.Element |
NavigationProvider | 4 documented props | ({ renderLink, pathname, onNavigate, children, }: NavigationProviderProps) => import("@types/react").JSX.Element |
useNavigation | export | () => NavigationContextValue |
AppShell props
Type: AppShellProps
Values the compiler reads from the declaration.
| Prop | Type | Default | Notes |
|---|---|---|---|
children | ReactNode | — | |
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.
| Prop | Type | Default | Notes |
|---|---|---|---|
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 | — | |
label | string | — | |
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.
| Prop | Type | Default | Notes |
|---|---|---|---|
className? | string | undefined | — | |
items | readonly 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.
| Prop | Type | Default | Notes |
|---|---|---|---|
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. |
title | ReactNode | — |
NavigationProvider props
Type: NavigationProviderProps
Values the compiler reads from the declaration.
| Prop | Type | Default | Notes |
|---|---|---|---|
children | ReactNode | — | |
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.
| Prop | Type | Default | Notes |
|---|---|---|---|
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.
| Prop | Type | Default | Notes |
|---|---|---|---|
children | ReactNode | — | |
title? | string | undefined | — |
AppShellMobileTrigger props1 props
AppShellMobileTrigger props
Type: { className?: string; }
Values the compiler reads from the declaration.
| Prop | Type | Default | Notes |
|---|---|---|---|
className? | string | undefined | — |
SidebarContent props1 props
SidebarContent props
Type: SidebarContentProps
Values the compiler reads from the declaration.
| Prop | Type | Default | Notes |
|---|---|---|---|
ariaLabel? | string | undefined | — | Accessible name for the navigation landmark. |
SidebarGroup props5 props
SidebarGroup props
Type: SidebarGroupProps
Values the compiler reads from the declaration.
| Prop | Type | Default | Notes |
|---|---|---|---|
badge? | ReactNode | — | Trailing marker beside the heading, e.g. a count. |
children | ReactNode | — | |
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.
| Prop | Type | Default | Notes |
|---|---|---|---|
children | ReactNode | — | |
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.
| Prop | Type | Default | Notes |
|---|---|---|---|
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.
| Prop | Type | Default | Notes |
|---|---|---|---|
active? | boolean | undefined | — | |
badge? | ReactNode | — | |
defaultOpen? | boolean | undefined | — | |
href? | string | undefined | — | |
icon? | ReactNode | — | |
label | string | — |
SidebarToggle props6 props
SidebarToggle props
Type: Omit<IconButtonProps, "label" | "icon"> & { label?: string; }
Values the compiler reads from the declaration.
| Prop | Type | Default | Notes |
|---|---|---|---|
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
BreadcrumbLink props
Type: BreadcrumbLinkProps
Values the compiler reads from the declaration.
| Prop | Type | Default | Notes |
|---|---|---|---|
children | ReactNode | — | |
href | string | — |
PageHeaderMeta props1 props
PageHeaderMeta props
Type: PageHeaderMetaProps
Values the compiler reads from the declaration.
| Prop | Type | Default | Notes |
|---|---|---|---|
marker? | ReactNode | — | A leading marker: a status dot, an icon. Decorative. |
NavigationLink props4 props
NavigationLink props
Type: NavigationLinkExtendedProps & { onNavigate?: (href: string) => void; }
Values the compiler reads from the declaration.
| Prop | Type | Default | Notes |
|---|---|---|---|
children | ReactNode | — | |
href | string | — | |
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`.
Related