Master detail
A list beside the selected record. Side by side and resizable on wide screens, with empty and loading states; on phones the record takes the screen with a Back button.
npx prodular-ui add master-detailApprovals inbox
Pick a request to load it. Drag the divider to resize. Narrow the window to see the phone view with Back.
Waiting for you5 requests
PR-1042
Needs approvalLumen Cloud Services
- Amount
- $18,400
- Category
- Cloud infrastructure
- Requested by
- Theo Lindqvist
- Waiting
- 2h
Reserved instances for Q4, 14% under on-demand pricing.
"use client"
import * as React from "react"
import { Badge } from "@/components/ui/badge"import { Button } from "@/components/ui/button"import { MasterDetail } from "@/components/ui/master-detail"
type Request = { id: string; vendor: string; requester: string; amount: number; category: string; age: string; note: string }
const requests: Request[] = [ { id: "PR-1042", vendor: "Lumen Cloud Services", requester: "Theo Lindqvist", amount: 18400, category: "Cloud infrastructure", age: "2h", note: "Reserved instances for Q4, 14% under on-demand pricing." }, { id: "PR-1041", vendor: "Northbeam Legal", requester: "Priya Raman", amount: 9200, category: "Legal", age: "5h", note: "MSA review for the Kestrel Health renewal." }, { id: "PR-1039", vendor: "Halcyon Freight", requester: "Hana Sato", amount: 3640, category: "Freight", age: "1d", note: "Expedited shipping for the Berlin office move." }, { id: "PR-1036", vendor: "Brightline Payroll", requester: "Maya Okafor", amount: 1250, category: "Payroll", age: "2d", note: "Year-end filing add-on." }, { id: "PR-1031", vendor: "Fable & Finch", requester: "Daniel Ferreira", amount: 780, category: "Office", age: "3d", note: "Client gifts for the December dinners." },]
const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 })
export default function MasterDetailApprovals() { const [selected, setSelected] = React.useState<string | null>("PR-1042") const [loading, setLoading] = React.useState(false) const current = requests.find((r) => r.id === selected)
const open = (id: string) => { setSelected(id) setLoading(true) setTimeout(() => setLoading(false), 350) }
return ( <div className="h-[460px] w-full overflow-hidden rounded-xl border bg-card shadow-xs"> <MasterDetail listHeader={ <div className="flex h-12 items-center justify-between px-4"> <span className="text-sm font-semibold">Waiting for you</span> <span className="text-[13px] text-muted-foreground tabular-nums">{requests.length} requests</span> </div> } list={ <ul className="divide-y"> {requests.map((r) => ( <li key={r.id}> <button type="button" aria-current={r.id === selected || undefined} onClick={() => open(r.id)} className="flex w-full flex-col gap-1 px-4 py-3 text-left outline-none hover:bg-accent/60 focus-visible:bg-accent aria-[current]:bg-accent" > <span className="flex items-center justify-between gap-3"> <span className="truncate text-sm font-medium">{r.vendor}</span> <span className="text-sm font-medium tabular-nums">{usd.format(r.amount)}</span> </span> <span className="flex items-center justify-between gap-3 text-[13px] text-muted-foreground"> <span className="truncate">{r.requester}</span> <span>{r.age}</span> </span> </button> </li> ))} </ul> } detail={ current ? ( <div className="flex flex-col gap-6 p-6"> <div className="flex items-start justify-between gap-4"> <div className="flex flex-col gap-1"> <span className="font-mono text-[13px] text-muted-foreground">{current.id}</span> <h3 className="text-lg font-semibold tracking-tight">{current.vendor}</h3> </div> <Badge variant="warning">Needs approval</Badge> </div> <dl className="grid grid-cols-2 gap-x-6 gap-y-4 text-sm"> <div> <dt className="text-[13px] text-muted-foreground">Amount</dt> <dd className="font-medium tabular-nums">{usd.format(current.amount)}</dd> </div> <div> <dt className="text-[13px] text-muted-foreground">Category</dt> <dd className="font-medium">{current.category}</dd> </div> <div> <dt className="text-[13px] text-muted-foreground">Requested by</dt> <dd className="font-medium">{current.requester}</dd> </div> <div> <dt className="text-[13px] text-muted-foreground">Waiting</dt> <dd className="font-medium">{current.age}</dd> </div> </dl> <p className="border-t pt-4 text-sm text-pretty text-muted-foreground">{current.note}</p> <div className="flex gap-2"> <Button size="sm">Approve</Button> <Button size="sm" variant="outline"> Ask a question </Button> </div> </div> ) : null } loading={loading} onBack={() => setSelected(null)} backLabel="Requests" /> </div> )}Anatomy
<MasterDetail list={rows} detail={selected ? <Record id={selected} /> : null} onBack={() => setSelected(null)} storageKey="approvals"/>Guidance
Use it when
- Inboxes, queues and lists where people go through records one by one: approvals, tickets, messages, leads.
- storageKey so the list width people chose survives reloads.
- loading while the selected record is fetched, so the pane does not flash empty.
Avoid
- Records that need the full page to edit. Open them on their own route.
- Comparing many columns. Use Data table or Data grid, and open the record in a Sheet.
Rules for AI agents
Published in llms-full.txt, the registry and the MCP server, so coding agents follow them when they build with this component.
- Pass the list as rows of buttons with aria-current on the selected one, and the selected record as detail, or null when nothing is selected.
- Set onBack to clear the selection; on phones it is the only way back to the list.
- Give MasterDetail a height through its parent (h-full in a page, or a fixed height in a card); both panes scroll on their own.
- Keep listHeader for the title, count, search or filters of the list.
API
MasterDetail
| Prop | Type | Default | Description |
|---|---|---|---|
| list / detail | ReactNode / ReactNode | null | The list and the selected record. | |
| listHeader | ReactNode | A bar over the list. | |
| onBack / backLabel | () => void / string | "Back" | Back to the list on phones. |
| empty | ReactNode | Shown when nothing is selected. | |
| loading | boolean | false | A skeleton in the detail pane. |
| resizable / defaultListSize / storageKey | boolean / number / string | true / 38 | The draggable divider, the list width in percent and where to remember it. |
Installation
The Prodular UI CLI adds the source to components/ui with everything it depends on. In a project that already uses shadcn/ui, the shadcn CLI installs it from the Prodular registry too. See Installation for the first setup.
npx prodular-ui add master-detail# or, with the registry in components.jsonnpx shadcn@latest add @prodular/master-detail