caliper uidocs

Hinge panel

Free

A list with a detail panel hinged to its edge. Choosing a row swings the panel open toward you from where the list ends, so the detail visibly belongs to the row you chose, and closing swings it back onto the list. The panel itself is flat, readable content.

Installs components/ui/hinge-panel.tsxDepends on @radix-ui/react-slot, lucide-reactMotion none in component source
pnpm dlx shadcn@latest add @caliper/hinge-panel
Select an invoice to open it beside the list.

When to use #

Use it for

  • Master and detail screens: invoices, orders, tickets, files.
  • A detail that should feel attached to its row, not like a separate page.

Skip it when

  • Details that need the whole screen; open a page or a sheet.
  • Several details at once; use the accordion or a table with expandable rows.

Decisions #

Why it is built this way, so you know what to keep when you change your copy.

Behaviour

Focus follows the swing
Enter on a row moves focus to the panel heading. Escape or Close shuts the panel and puts focus back on the same row.
A shut panel cannot be tabbed into
It is inert and its back face is hidden, so keyboard and screen reader users never meet a panel nobody can see.

Styling

Only the swing is 3D
The content never sits on a tilted plane for longer than the swing takes. Under 520px the panel covers the list from the same hinge.

Installation #

terminal
pnpm dlx shadcn@latest add @caliper/hinge-panel

Adds its 4 files to components/ui/, each with its own stylesheet, and installs their packages. No account needed. The first part also adds Caliper UI’s tokens; import them once as in Installation.

Usage #

This is the code behind the preview above.

example.tsx
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { HingePanel, type HingeItem } from "@/components/ui/hinge-panel"

const invoices: (HingeItem & { lines: [string, string][]; status: string; total: string })[] = [
  { id: "inv-2041", title: "Northwind Traders", heading: "INV-2041", subtitle: "INV-2041 · Due Oct 21", trailing: "$1,240.00", status: "Due Oct 21", total: "$1,240.00", lines: [["Pro seats × 12", "$1,188.00"], ["Tax", "$52.00"]] },
  { id: "inv-2040", title: "Acme Co", heading: "INV-2040", subtitle: "INV-2040 · Paid Oct 03", trailing: "$199.00", status: "Paid Oct 03", total: "$199.00", lines: [["Team plan", "$199.00"]] },
  { id: "inv-2039", title: "Initech", heading: "INV-2039", subtitle: "INV-2039 · Overdue 6 days", trailing: "$99.00", status: "Overdue 6 days", total: "$99.00", lines: [["Pro plan", "$99.00"]] },
  { id: "inv-2038", title: "Globex Corporation International Holdings, Procurement and Accounts Payable", heading: "INV-2038", subtitle: "INV-2038 · Due Nov 02", trailing: "$4,980.00", status: "Due Nov 02", total: "$4,980.00", lines: [["Enterprise annual", "$4,800.00"], ["Support add-on", "$180.00"]] },
];

export function HingePanelDemo() {
  const [sent, setSent] = useState<string[]>([]);
  return (
    <div className="w-full max-w-3xl">
      <HingePanel
        label="Invoices"
        detailLabel="Invoice details"
        placeholder="Select an invoice to open it beside the list."
        items={invoices}
        renderDetail={(item) => {
          const invoice = invoices.find((row) => row.id === item.id)!;
          return (
            <>
              <p className="m-0 text-[13px] text-muted-foreground [overflow-wrap:anywhere]">{invoice.title}</p>
              <dl className="m-0 grid gap-1.5">
                {invoice.lines.map(([name, amount]) => (
                  <div key={name} className="flex justify-between gap-2.5 border-b border-border py-1.5 text-[13px]"><dt className="text-muted-foreground">{name}</dt><dd className="m-0 text-right font-mono">{amount}</dd></div>
                ))}
                <div className="flex justify-between gap-2.5 border-b border-border py-1.5 text-[13px]"><dt className="text-muted-foreground">Total</dt><dd className="m-0 text-right font-mono font-bold">{invoice.total}</dd></div>
                <div className="flex justify-between gap-2.5 border-b border-border py-1.5 text-[13px]"><dt className="text-muted-foreground">Status</dt><dd className="m-0 text-right">{invoice.status}</dd></div>
              </dl>
              <div>
                <Button size="sm" disabled={sent.includes(invoice.id)} onClick={() => setSent((cur) => [...cur, invoice.id])}>{sent.includes(invoice.id) ? "Reminder sent" : "Send reminder"}</Button>
              </div>
            </>
          );
        }}
      />
    </div>
  );
}

API reference #

HingePanel

component
PropTypeDefaultDescription
items *readonly HingeItem[]—
label *string—Accessible name of the list.
renderDetail *(item: HingeItem) => ReactNode—The panel body for the open row. The heading and the Close button are drawn for you.
openIdstring | null—The open row, or null for none. Controlled when set.
defaultOpenIdstring | nullnull
onOpenChange((id: string | null) => void)—
detailLabelstring"Details"Accessible name of the panel.
placeholderstring"Select an item to open it beside the list."What the empty slot beside the list says while nothing is open.
closeLabelstring"Close"
statusDepthStatus"ready"
messagesDepthMessages—
onRetry(() => void)—
disabledboolean—
classNamestring—

Accessibility #

It is built on native elements and keeps aria-label, aria-expanded and aria-controls in step with state.

No custom key handling; Tab and the native activation keys do the work.

Motion #

It declares no animation in its component source. Any transition comes from its stylesheet, which uses the duration tokens that collapse to instant under prefers-reduced-motion.