Hinge panel
FreeA 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.
pnpm dlx shadcn@latest add @caliper/hinge-panelWhen 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 #
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.
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
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.