65 / Wayfinding
Side Panel
A pane that comes in from the side: an inspector, a filter set, a row's detail.
"use client"
import * as React from "react"
import { SidePanel } from "mischief-ui/side-panel"
const button =
"border-border hover:bg-muted inline-flex min-h-9 items-center rounded-full border px-3 text-sm"
export function SidePanelDemo() {
const [open, setOpen] = React.useState(false)
const [child, setChild] = React.useState(false)
const [beside, setBeside] = React.useState(false)
return (
<div className="flex flex-wrap items-center gap-3">
<button className={button} type="button" onClick={() => setOpen(true)}>
Open a panel
</button>
<button className={button} type="button" onClick={() => setBeside(true)}>
Open one you can work beside
</button>
<SidePanel
description="Open another from inside it; this one stays visible behind."
footer={
<button
className={button}
type="button"
onClick={() => setOpen(false)}
>
Done
</button>
}
open={open}
title="Northwind Traders"
onOpenChange={setOpen}
>
<div className="grid gap-3 text-sm">
<button
className={button}
type="button"
onClick={() => setChild(true)}
>
Open the invoice
</button>
{Array.from({ length: 12 }, (_, index) => (
<p key={index} className="text-muted-foreground">
Line {index + 1} — the body scrolls while the header and footer
stay where they are.
</p>
))}
<SidePanel
open={child}
title="Invoice 4021"
onOpenChange={setChild}
footer={
<button
className={button}
type="button"
onClick={() => setChild(false)}
>
Back
</button>
}
>
<p className="text-muted-foreground text-sm">
Opened from inside the panel behind it, and set in from the edge
so that one is still there.
</p>
</SidePanel>
</div>
</SidePanel>
<SidePanel
description="The page behind stays scrollable and clickable."
hideBackdrop
modal="trap-focus"
open={beside}
title="Inspector"
onOpenChange={setBeside}
>
<p className="text-muted-foreground text-sm">
Focus is still kept inside, so tabbing does not wander off into the
page. Nothing behind is locked.
</p>
</SidePanel>
</div>
)
}Installation
Copy the source into your project, or keep it behind a package.
npx shadcn@latest add Tinkerers-Labs/mischief-ui/side-panelimport { SidePanel } from "mischief-ui/side-panel"Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.
"use client" import * as React from "react"import { Dialog } from "@base-ui/react/dialog"import { X } from "lucide-react" import { cn } from "@/lib/utils" export type SidePanelSide = "left" | "right" export type SidePanelProps = { open: boolean onOpenChange: (open: boolean) => void /**Usage
export function Inspector({ row, onClose }) {
return (
<SidePanel
open={row !== null}
onOpenChange={(open) => !open && onClose()}
title={row?.name}
description="Everything we hold about this record."
footer={<button onClick={onClose}>Done</button>}
>
<RecordDetail row={row} />
</SidePanel>
)
}What moves and what stays
The header, the toolbar, and the footer are pinned; only the body scrolls. That is the difference between a panel and a long page pushed sideways: the thing you opened it to do stays reachable however far down you read.
Below the medium breakpoint it takes the full width, because a 28rem pane on a phone is a modal with a stripe of unusable backdrop beside it.
Taking over, or sitting beside
By default it is a modal: focus is trapped, the page behind does not scroll, Escape closes it, and focus returns to whatever opened it. That suits a panel you finish with before carrying on.
For an inspector you work beside -- a list you keep clicking while the panel stays open -- pass modal="trap-focus". The page behind stays scrollable and clickable, and focus is still kept inside the panel so tabbing does not wander off into it. Pair it with hideBackdrop, since a scrim over a page you can still use is a lie.
<SidePanel
modal="trap-focus"
hideBackdrop
open={open}
title="Inspector"
onOpenChange={setOpen}
/>A panel from a panel
Render a Side Panel inside another and it sets itself in from the edge by stackOffset, so the one behind stays visible as a strip rather than disappearing under it. Depth is counted for you: nothing has to be passed down, and a panel three levels in knows where it is.
<SidePanel open={open} title="Customer" onOpenChange={setOpen}>
<button onClick={() => setInvoice(true)}>Open the invoice</button>
<SidePanel open={invoice} title="Invoice" onOpenChange={setInvoice}>
...
</SidePanel>
</SidePanel>Refusing to close
A panel holding a half-finished form should not vanish because someone pressed Escape or clicked past it. closeOnEscape={false} and dismissible={false} take those away, leaving the close control and whatever you put in the footer as the ways out.
Take them away only when there is something to lose. A panel that cannot be dismissed and has no obvious way out is a trap, so keep the close control visible whenever you do.
API
openbooleanWhether it is showing. The state is yours.onOpenChange(open: boolean) => voidCalled on Escape, on the backdrop, and by the close control.side"left" | "right"Which edge it comes from. Defaults to "right".titleReactNodeNames the panel, and the dialog.descriptionReactNodeA line under the title, and the dialog's description.toolbarReactNodePinned above the body: filters, a search, tabs.footerReactNodePinned below it: the actions that apply or close.modalboolean | "trap-focus"trap-focus keeps the page usable behind it.dismissiblebooleanClose when the backdrop is pressed. Defaults to true.closeOnEscapebooleanClose on Escape. Turn off where there is unsaved work.hideBackdropbooleanLeave the scrim out.stackOffsetstringHow far a panel opened inside another sits from the edge.durationnumberMilliseconds the slide takes. Defaults to 250.widthstringWidth from the medium breakpoint up. Defaults to "28rem".Accessibility
A real dialog: the title names it, the description is read after that name, focus is trapped and restored, and Escape closes it unless you have said otherwise. The panel slides from its edge and stops sliding under reduced motion, arriving in place instead. The close control is named and is the first thing reached after the heading, so leaving never means hunting.