Skip to contentVibraUI
Navigation & layout

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-panel

Needs the @vibra registry in your components.json — set it up once.

Examples

Inline

A column that opens beside the list instead of over it.

Props

PropTypeDefaultDescription
openboolean—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.
titleReact.ReactNode—Names the panel, in the header and to a screen reader.
descriptionReact.ReactNode—A quiet second line under the title.
childrenReact.ReactNode—The panel body; it scrolls on its own.
footerReact.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.
returnFocusRefReact.RefObject<HTMLElement | null>whatever opened the panelWhere focus goes when an inline panel closes; the overlay's sheet restores focus itself.
classNamestring—Classes for the panel itself, in either mode.

Dependencies

Source

components/ui/inspector-panel.tsx
"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 }