Inspector panel
The details panel beside a list: a sheet over the page, or a column that opens in it.
Two modes over one header, body, and footer layout. overlay is the upstream Sheet, so it keeps the dialog role, the focus trap, and Escape to close. inline is an aside beside the content: role="complementary", named by its own title, animating its width from zero — and when it is closed it is not merely narrow but gone, hidden from the accessibility tree and skipped by the tab order, so nothing focusable hides in a zero-width column. The content inside keeps its full width while the shell animates, so the header and body slide out of view instead of reflowing on every frame. An inline panel is not a dialog, so nothing restores focus for it: it remembers what was focused when it opened and hands focus back when its own close button shuts it, or to returnFocusRef when you name a destination. InspectorPanelHeader, InspectorPanelBody, and InspectorPanelFooter are exported for hand-composed panels.
Install
npx shadcn@latest add @vibra/inspector-panelNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { InspectorPanel } from "@/components/ui/inspector-panel"
const REQUESTS = [
{ id: "req_9f21", route: "POST /v1/charges", status: 201, ms: 142, at: "12:04:11" },
{ id: "req_9f20", route: "GET /v1/customers", status: 200, ms: 38, at: "12:04:09" },
{ id: "req_9f1f", route: "POST /v1/webhooks", status: 502, ms: 3011, at: "12:03:58" },
{ id: "req_9f1e", route: "GET /v1/invoices", status: 200, ms: 61, at: "12:03:52" },
]
export default function InspectorPanelDemo() {
const [openId, setOpenId] = React.useState<string | null>(null)
const active = REQUESTS.find((request) => request.id === openId)
return (
<div className="h-[360px] w-full max-w-2xl overflow-auto rounded-lg border">
<ul className="divide-y">
{REQUESTS.map((request) => (
<li key={request.id}>
<button
type="button"
onClick={() => setOpenId(request.id)}
className="flex w-full items-center gap-3 px-3 py-2.5 text-left outline-none transition-colors hover:bg-muted/60 focus-visible:bg-muted/60"
>
<Badge variant={request.status >= 500 ? "destructive" : "secondary"}>
{request.status}
</Badge>
<span className="min-w-0 flex-1 truncate font-mono text-xs">{request.route}</span>
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
{request.ms} ms
</span>
</button>
</li>
))}
</ul>
<InspectorPanel
open={active !== undefined}
onOpenChange={(open) => {
if (!open) setOpenId(null)
}}
title={active?.route ?? "Request"}
description={active ? `${active.id} · ${active.at}` : undefined}
footer={
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={() => setOpenId(null)}>
Close
</Button>
<Button size="sm">Replay request</Button>
</div>
}
>
<dl className="grid grid-cols-[7rem_1fr] gap-x-4 gap-y-2">
<dt className="text-muted-foreground">Status</dt>
<dd className="tabular-nums">{active?.status}</dd>
<dt className="text-muted-foreground">Duration</dt>
<dd className="tabular-nums">{active?.ms} ms</dd>
<dt className="text-muted-foreground">Region</dt>
<dd>eu-central-1</dd>
<dt className="text-muted-foreground">Idempotency</dt>
<dd className="truncate font-mono text-xs">key_5c2a91</dd>
</dl>
</InspectorPanel>
</div>
)
}Inline
A column that opens beside the list instead of over it.
"use client"
import * as React from "react"
import { PanelRightIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { InspectorPanel } from "@/components/ui/inspector-panel"
const ROWS = [
{ id: "cus_2841", name: "Blue Harbor Logistics", plan: "Pro", mrr: "$1,240" },
{ id: "cus_2840", name: "Northwind Analytics", plan: "Enterprise", mrr: "$8,900" },
{ id: "cus_2839", name: "Hedgerow Studio", plan: "Starter", mrr: "$79" },
{ id: "cus_2838", name: "Bramble & Co", plan: "Pro", mrr: "$1,240" },
]
export default function InspectorPanelInline() {
const [open, setOpen] = React.useState(true)
const [activeId, setActiveId] = React.useState("cus_2840")
const active = ROWS.find((row) => row.id === activeId) ?? ROWS[0]
return (
<div className="flex h-[360px] w-full max-w-3xl overflow-hidden rounded-lg border">
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex h-11 shrink-0 items-center justify-between gap-2 border-b px-3">
<span className="text-sm font-medium">Customers</span>
<Button
variant="ghost"
size="icon-sm"
aria-label={open ? "Hide details" : "Show details"}
aria-pressed={open}
onClick={() => setOpen((value) => !value)}
>
<PanelRightIcon />
</Button>
</div>
<ul className="min-h-0 flex-1 divide-y overflow-auto">
{ROWS.map((row) => (
<li key={row.id}>
<button
type="button"
onClick={() => {
setActiveId(row.id)
setOpen(true)
}}
aria-current={row.id === active.id ? "true" : undefined}
className="flex w-full items-center gap-3 px-3 py-2.5 text-left outline-none transition-colors hover:bg-muted/60 focus-visible:bg-muted/60 aria-[current]:bg-accent"
>
<span className="min-w-0 flex-1 truncate text-sm">{row.name}</span>
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
{row.mrr}
</span>
</button>
</li>
))}
</ul>
</div>
<InspectorPanel
mode="inline"
width="sm"
open={open}
onOpenChange={setOpen}
title={active.name}
description={active.id}
>
<dl className="grid grid-cols-[5rem_1fr] gap-x-3 gap-y-2">
<dt className="text-muted-foreground">Plan</dt>
<dd>{active.plan}</dd>
<dt className="text-muted-foreground">MRR</dt>
<dd className="tabular-nums">{active.mrr}</dd>
<dt className="text-muted-foreground">Owner</dt>
<dd>Ada Lovelace</dd>
<dt className="text-muted-foreground">Since</dt>
<dd>Mar 2024</dd>
</dl>
</InspectorPanel>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| open | boolean | — | Whether the panel is showing; always controlled. |
| onOpenChange | (open: boolean) => void | — | Called by the close button, and by the sheet's own dismissals in overlay mode. |
| title | React.ReactNode | — | Names the panel, in the header and to a screen reader. |
| description | React.ReactNode | — | A quiet second line under the title. |
| children | React.ReactNode | — | The panel body; it scrolls on its own. |
| footer | React.ReactNode | — | A pinned bottom row, usually the actions. |
| side | "right" | "left" | "right" | Which edge it sits against, and which side its hairline is drawn on. |
| width | "sm" | "default" | "lg" | "default" | 20rem, 24rem, or 28rem. |
| mode | "inline" | "overlay" | "overlay" | overlay floats over the page as a sheet; inline sits beside it in the flow. |
| returnFocusRef | React.RefObject<HTMLElement | null> | whatever opened the panel | Where focus goes when an inline panel closes; the overlay's sheet restores focus itself. |
| className | string | — | Classes for the panel itself, in either mode. |
Dependencies
Registry
npm
Source
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Sheet,
SheetContent,
SheetDescription,
SheetTitle,
} from "@/components/ui/sheet"
/** How wide the panel opens: 20rem, 24rem, or 28rem. */
export type InspectorWidth = "sm" | "default" | "lg"
const WIDTH: Record<InspectorWidth, string> = {
sm: "w-80",
default: "w-96",
lg: "w-[28rem]",
}
// COUPLED TO registry/vibra/ui/sheet.tsx: SheetContent sizes itself with
// side-scoped classes (data-[side=right]:w-3/4, data-[side=right]:sm:max-w-sm)
// whose attribute selector outranks a plain width class, so the panel's own
// width only lands when it is marked important. If the primitive stops scoping
// its width by side, drop the "!" and merge normally.
const OVERLAY_WIDTH: Record<InspectorWidth, string> = {
sm: "w-80!",
default: "w-96!",
lg: "w-[28rem]!",
}
export type InspectorPanelProps = {
open: boolean
onOpenChange: (open: boolean) => void
title: React.ReactNode
description?: React.ReactNode
children: React.ReactNode
footer?: React.ReactNode
side?: "right" | "left"
width?: InspectorWidth
/** overlay floats over the page as a sheet; inline sits beside it and animates its width. */
mode?: "inline" | "overlay"
/** Where focus goes when an inline panel closes; without it, whatever opened the panel. */
returnFocusRef?: React.RefObject<HTMLElement | null>
className?: string
}
/** The title row, with whatever closes the panel on its right. */
function InspectorPanelHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="inspector-panel-header"
className={cn("flex shrink-0 items-start justify-between gap-2 border-b border-rule px-4 py-3", className)}
{...props}
/>
)
}
/** The scrolling middle of the panel. */
function InspectorPanelBody({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="inspector-panel-body"
className={cn("min-h-0 flex-1 overflow-y-auto px-4 py-3 text-sm", className)}
{...props}
/>
)
}
/** The pinned bottom row, usually the actions for whatever is being inspected. */
function InspectorPanelFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="inspector-panel-footer"
className={cn("shrink-0 border-t border-rule px-4 py-3", className)}
{...props}
/>
)
}
/** The details panel beside a list: a sheet over the page, or a column that opens in it. */
function InspectorPanel({
open,
onOpenChange,
title,
description,
children,
footer,
side = "right",
width = "default",
mode = "overlay",
returnFocusRef,
className,
}: InspectorPanelProps) {
const titleId = `${React.useId()}-title`
// An inline panel is a landmark, not a dialog, so nothing restores focus for
// it: closing it from its own button would leave focus on a control that is
// about to go inert, and the next Tab would start again from the top of the
// page. Remember what was focused when the panel opened, and hand it back.
// The overlay needs none of this — the Sheet restores focus itself.
const opener = React.useRef<HTMLElement | null>(null)
const wasOpen = React.useRef(open)
React.useEffect(() => {
if (open && !wasOpen.current) opener.current = document.activeElement as HTMLElement | null
wasOpen.current = open
}, [open])
function requestClose() {
onOpenChange(false)
if (mode !== "inline") return
const target = returnFocusRef?.current ?? opener.current
// Synchronous: focus has to leave the close button before the render that
// makes the panel inert.
target?.focus()
}
const close = (
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Close"
className="-me-1.5 shrink-0 text-muted-foreground"
onClick={requestClose}
>
<XIcon />
</Button>
)
const body = (
<>
<InspectorPanelBody>{children}</InspectorPanelBody>
{footer ? <InspectorPanelFooter>{footer}</InspectorPanelFooter> : null}
</>
)
if (mode === "overlay") {
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side={side}
showCloseButton={false}
data-slot="inspector-panel"
data-mode="overlay"
data-side={side}
data-width={width}
data-open="true"
className={cn("gap-0 max-w-[calc(100%-2rem)]!", OVERLAY_WIDTH[width], className)}
>
<InspectorPanelHeader>
<div className="flex min-w-0 flex-col gap-0.5">
<SheetTitle className="truncate font-sans text-md tracking-normal">{title}</SheetTitle>
{description ? (
<SheetDescription className="truncate text-xs">{description}</SheetDescription>
) : null}
</div>
{close}
</InspectorPanelHeader>
{body}
</SheetContent>
</Sheet>
)
}
return (
<aside
// A landmark rather than a dialog: an inline panel sits in the page and
// never traps focus. Closed, it is not just narrow but gone — hidden
// from the accessibility tree and skipped by the tab order.
role="complementary"
aria-labelledby={titleId}
aria-hidden={open ? undefined : "true"}
inert={!open}
data-slot="inspector-panel"
data-mode="inline"
data-side={side}
data-width={width}
data-open={open || undefined}
className={cn(
"flex shrink-0 flex-col overflow-hidden bg-card transition-[width] duration-(--duration-base) ease-(--ease-emphasized)",
side === "right" ? "border-s" : "border-e",
open ? WIDTH[width] : "w-0 border-transparent",
className
)}
>
{/* Fixed width inside the animating shell, so the header and body slide
out of view rather than reflowing on every frame of the transition. */}
<div className={cn("flex h-full flex-col", WIDTH[width])}>
<InspectorPanelHeader>
<div className="flex min-w-0 flex-col gap-0.5">
<p id={titleId} className="truncate text-md font-medium">
{title}
</p>
{description ? (
<p className="truncate text-xs text-muted-foreground">{description}</p>
) : null}
</div>
{close}
</InspectorPanelHeader>
{body}
</div>
</aside>
)
}
export { InspectorPanel, InspectorPanelBody, InspectorPanelFooter, InspectorPanelHeader }