App layout
ProThe app frame: sidebar, main column, and an optional side panel the person can resize by dragging (or with the arrow keys on the divider). With resizable={false} the panel keeps defaultPanel's width and the divider is a plain border. Dragging is never the only way: a click on the divider steps the panel through its narrowest, starting and widest widths, and Home and End jump to either end.
It responds to its own width, not the window's, so it behaves the same full-screen or embedded. Narrower than sidebarAt, the sidebar becomes a sheet behind a menu button; narrower than panelAt, the panel becomes a sheet from the right.
The sidebar sheet's open state is yours to control when you need it (navOpen and onNavOpenChange), so you can close it when the person navigates. With your own top bar, navButton={false} drops the built-in menu button; open the sheet from yours. The frame carries data-nav ("rail" or "sheet") and the group/app-layout class, so your button can show only while the sidebar is a sheet: group-data-[nav=rail]/app-layout:hidden.
Preview
Install
First time? Get started sets up the registry and theme. Or with shadcn directly: npx shadcn@latest add @candor/app-layout
Usage
"use client"
import * as React from "react"
import { Inbox, MessagesSquare, PanelRight, Settings } from "lucide-react"
import { AppLayout } from "@/components/app-layout"
import { AppSidebar } from "@/components/app-sidebar"
import { EnvironmentBand } from "@/components/environment-band"
import type { Environment } from "@/lib/team"
const staging: Environment = { id: "staging", name: "Storefront", detail: "staging", production: false, hue: 2, connection: "connected" }
export default function Example() {
const [place, setPlace] = React.useState("threads")
const [panelOpen, setPanelOpen] = React.useState(false)
const [fixed, setFixed] = React.useState(false)
return (
<div className="h-[420px] overflow-hidden rounded-lg border">
<AppLayout
// Sized for this preview box; in a full-screen app the defaults (768 and 1100) suit.
sidebarAt={560}
panelAt={640}
minPanel={200}
defaultPanel={220}
maxPanel={320}
// resizable={false} keeps the panel at defaultPanel's width, with no drag handle.
resizable={!fixed}
sidebar={
<AppSidebar
brand={<span className="px-1 text-sm font-semibold">Keel</span>}
nav={[
{ id: "threads", label: "Threads", Icon: MessagesSquare },
{ id: "inbox", label: "Inbox", Icon: Inbox, count: 2, urgent: true },
{ id: "settings", label: "Settings", Icon: Settings },
]}
activeNav={place}
onNavigate={setPlace}
user={{ name: "Sam", email: "sam@acme.example" }}
/>
}
top={<EnvironmentBand account="Acme" environment={staging} />}
panel={
<div className="flex flex-col gap-2 p-4 text-sm">
<p className="font-medium">0042_add_region</p>
<p className="text-muted-foreground">Adds a nullable region column to orders. Ran on Staging in 1.8s.</p>
</div>
}
panelOpen={panelOpen}
onPanelOpenChange={setPanelOpen}
panelLabel="Migration"
>
<header className="flex h-11 shrink-0 items-center gap-2 border-b px-4">
<h2 className="min-w-0 flex-1 truncate text-sm font-semibold">Add a region column to orders</h2>
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
<input type="checkbox" checked={fixed} onChange={(e) => setFixed(e.target.checked)} />
Fixed width
</label>
<button
type="button"
aria-label="Migration panel"
aria-pressed={panelOpen}
onClick={() => setPanelOpen((o) => !o)}
className="flex size-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
>
<PanelRight className="size-4" />
</button>
</header>
<div className="flex-1 overflow-y-auto p-4 text-sm text-muted-foreground">
The migration is ready on Staging. Open the panel to see what it changes.
</div>
</AppLayout>
</div>
)
}<AppLayout>
| Prop | Type | Default | Description |
|---|---|---|---|
| sidebarrequired | React.ReactNode | — | The left rail (an AppSidebar); a sheet behind a menu button when narrow. |
| top | React.ReactNode | undefined | — | A strip above the main column (an OrgBand). |
| childrenrequired | React.ReactNode | — | What goes inside. |
| panel | React.ReactNode | undefined | — | The side panel's content, shown right of the main column when `panelOpen`. |
| panelOpen | boolean | undefined | false | Whether the side panel is showing. Defaults to false. |
| onPanelOpenChange | ((open: boolean) => void) | undefined | — | Called when the panel opens or closes, including when a narrow frame closes it. |
| panelLabel | string | undefined | — | The panel's accessible name, also used for the resize handle. Defaults to "Details". |
| minPanel | number | undefined | 320 | The narrowest the panel can be dragged, in pixels. Defaults to 320. |
| maxPanel | number | undefined | 720 | The widest the panel can be dragged, in pixels. Defaults to 720. |
| defaultPanel | number | undefined | 400 | The panel's starting width, in pixels, and its fixed width when it isn't resizable. Defaults to 400. |
| resizable | boolean | undefined | true | Whether the person can drag (or arrow-key) the panel's width between `minPanel` and `maxPanel`. Defaults to true. |
| sidebarAt | number | undefined | 768 | Frame width in pixels below which the sidebar becomes a sheet. Defaults to 768. |
| panelAt | number | undefined | 1100 | Frame width in pixels below which the panel becomes a sheet from the right. Defaults to 1100. |
| defaultWidth | number | undefined | 0 | The frame width to lay out for until it can be measured: on the server, and in test environments like jsdom that measure everything as 0. Defaults to 0, the narrow layout (sidebar and panel as sheets). |
| navOpen | boolean | undefined | — | Whether the sidebar sheet is open, when narrow. Give it to control the sheet yourself; leave it out and AppLayout keeps its own state. |
| onNavOpenChange | ((open: boolean) => void) | undefined | — | Called when the sidebar sheet opens or closes: from the menu button, Escape, a click outside, or the frame growing wide enough for the rail. |
| navButton | boolean | undefined | true | Shows the built-in menu button that opens the sidebar sheet when narrow. Defaults to true; with false, open the sheet from your own button through `navOpen`. |
| className | string | undefined | — | Extra classes for the outer element. |
Strings
The fixed words, in English by default. Change any of them under appLayout in <LocaleProvider strings={{ … }}>; the table is exported as appLayoutStrings.
| Key | English |
|---|---|
| navigation | Navigation |
| openNavigation | Open navigation |
| details | Details |
| resizePanel | Resize {panel} panel |