Side panel
ProThe side panel's contents: one tab per open item (a diff, an approval, a Know document), each closable. Closing the last tab closes the panel.
Arrow keys move between tabs. With onClose, each tab has a close button for the pointer and Delete closes the focused tab, moving focus to its neighbour. The tab's button stays out of the tab order so the tab list holds only tabs; the panel starts with a "Close" button that shows when focused, so screen reader and touch users have a way too. Without activeId, the panel keeps track of the chosen tab itself. A tab can carry a count, and actions puts buttons at the end of the tab row, outside the tab list.
With keepTabs, switching tabs keeps each one as it was (its scroll, a half-filled form) instead of starting it again. On React 19.2 and later a hidden tab's effects pause until it's shown again.
Preview
Install
First time? Get started sets up the registry and theme. Or with shadcn directly: npx shadcn@latest add @candor/side-panel
Brings in: locale, utils · npm: lucide-react@^1.67.0, radix-ui@^1.8.1
Usage
"use client"
import * as React from "react"
import { FileDiff, Maximize2, ShieldCheck } from "lucide-react"
import { SidePanel, type PanelTab } from "@/components/side-panel"
const allTabs: PanelTab[] = [
{
id: "approval",
label: "Run migration on Production",
Icon: ShieldCheck,
// A count on the tab; "attention" for things waiting on the person, with words for screen readers.
count: 1,
countLabel: "1 needs you",
countTone: "attention",
content: (
<div className="flex flex-col gap-2 text-sm">
<p className="font-medium">Dev agent wants to run 0042_add_region on Production.</p>
<p className="text-muted-foreground">Adds a nullable region column to orders. It ran on Staging in 1.8s.</p>
</div>
),
},
{
id: "diff",
label: "Diff · 2 files",
Icon: FileDiff,
content: (
<pre className="overflow-x-auto rounded-md bg-muted p-3 font-mono text-xs">
{`+ ALTER TABLE orders\n+ ADD COLUMN region text;\n+ CREATE INDEX orders_region_idx\n+ ON orders (region);`}
</pre>
),
},
]
export default function Example() {
const [open, setOpen] = React.useState(allTabs.map((t) => t.id))
const [active, setActive] = React.useState("approval")
const tabs = allTabs.filter((t) => open.includes(t.id))
return (
<div className="h-80 max-w-md overflow-hidden rounded-lg border bg-chrome">
{tabs.length > 0 ? (
<SidePanel
tabs={tabs}
activeId={active}
onActivate={setActive}
actions={
<button type="button" aria-label="Open full width" className="flex size-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground">
<Maximize2 className="size-3.5" />
</button>
}
onClose={(id) => {
const next = open.filter((t) => t !== id)
setOpen(next)
if (id === active && next[0]) setActive(next[0])
}}
/>
) : (
<div className="flex h-full items-center justify-center">
<button type="button" onClick={() => setOpen(allTabs.map((t) => t.id))} className="text-sm text-muted-foreground underline underline-offset-2">
Reopen the panel
</button>
</div>
)}
</div>
)
}<SidePanel>
| Prop | Type | Default | Description |
|---|---|---|---|
| tabsrequired | PanelTab[] | — | The open items, one tab each, with their content. |
| activeId | string | undefined | — | The id of the tab showing; without one (or an unknown id), the first tab. |
| onActivate | ((id: string) => void) | undefined | — | Called with a tab's id when it's chosen. |
| onClose | ((id: string) => void) | undefined | — | Shows a close button on each tab, and lets Delete close the focused tab; called with the tab's id. |
| actions | React.ReactNode | undefined | — | Buttons at the end of the tab row (hide the panel, open full width), kept out of the tab list's arrow keys. |
| keepTabs | boolean | undefined | false | Keeps tabs that aren't showing mounted, so each keeps its state and scroll position. Defaults to false: only the showing tab is rendered. |
| className | string | undefined | — | Extra classes for the outer element. |
Strings
The fixed words, in English by default. Change any of them under sidePanel in <LocaleProvider strings={{ … }}>; the table is exported as sidePanelStrings.
| Key | English |
|---|---|
| deleteHint | Press Delete to close. |
| close | Close {label} |
| count | { one: "{count} item", other: "{count} items" } |
| countAfterName | , {label} |
Types
- PanelTab
{ id: string label: string Icon?: React.ComponentType<{ className?: string | undefined }> | undefined content: React.ReactNode /** A number shown on the tab, such as what needs the person there. Nothing is shown for 0 or when left out. */ count?: number | undefined /** What `count` means, read by screen readers after the tab's name ("2 need you"). Defaults to "{count} items". */ countLabel?: strin