Mischief

65 / Wayfinding

Side Panel

A pane that comes in from the side: an inspector, a filter set, a row's detail.

Installation

Copy the source into your project, or keep it behind a package.

npx shadcn@latest add Tinkerers-Labs/mischief-ui/side-panel
import { SidePanel } from "mischief-ui/side-panel"
Also installs
  • @base-ui/react
  • lucide-react

Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.

registry/default/side-panel/side-panel.tsx
"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.

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>
Each level is a dialog of its own, so Escape closes the top one first.

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.