# Detail Layout

> A React detail layout for record pages (a client, a contact, an invoice): header, a main column of sections, a side panel and section cards.

- Type: React component (Layout)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.0.0
- Install: `npx wingo-ui@latest add detail-layout`
- Docs: https://wingo-ui.com/components/detail-layout

## Interaction

Read a record the way it is built: the header on top, the main sections (activity, notes, invoices) on the left and the side panel (details, contacts, files) beside them once the layout itself is wide enough (960px of content), which sticks while the long column scrolls and scrolls on its own when it is taller than the screen. Below the breakpoint the panel moves under the main column, above it, or into a second tab: a full-width segmented control that sticks under the top bar, where a tap (or Tab then the arrow keys) switches panes with a short fade and both panes keep their scroll and state. Every section card has a title with a count, a pencil to edit and a ⋯ menu (an action sheet on phones); a collapsible section opens and closes from anywhere on its header row, with a mouse, a tap, or Enter / Space on the focused title, and its chevron turns. Loading sections show skeleton lines and fade into their content; empty ones show a small empty state with an action. Tab order is always header, main, side panel.

## Installation

```bash
npx wingo-ui@latest add detail-layout
```

Pro item: run `npx wingo-ui@latest login` once with a Pro account. The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add @wingo-ui/detail-layout` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/ui/detail-layout.tsx`

npm dependencies: `npm i motion lucide-react`

Wingo UI dependencies (installed automatically):

- [Badge](https://wingo-ui.com/components/badge) ([Markdown](https://wingo-ui.com/components/badge.md))
- [Button](https://wingo-ui.com/components/button) ([Markdown](https://wingo-ui.com/components/button.md))
- [Collapsible](https://wingo-ui.com/components/collapsible) ([Markdown](https://wingo-ui.com/components/collapsible.md))
- [Dropdown Menu](https://wingo-ui.com/components/dropdown-menu) ([Markdown](https://wingo-ui.com/components/dropdown-menu.md))
- [Empty State](https://wingo-ui.com/components/empty-state) ([Markdown](https://wingo-ui.com/components/empty-state.md))
- [Segmented Control](https://wingo-ui.com/components/segmented-control) ([Markdown](https://wingo-ui.com/components/segmented-control.md))
- [Skeleton](https://wingo-ui.com/components/skeleton) ([Markdown](https://wingo-ui.com/components/skeleton.md))
- [useControllableState](https://wingo-ui.com/components/use-controllable-state) ([Markdown](https://wingo-ui.com/components/use-controllable-state.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))

## Usage

```tsx
import { Activity, FolderOpen, Info, Pencil, Receipt, Trash2 } from "lucide-react"
import { DetailLayout, DetailSection } from "@/components/ui/detail-layout"
import { EntityHeader } from "@/components/ui/entity-header"

export function ClientPage() {
  return (
    <div className="px-4 pb-12 md:px-6">
      <DetailLayout
        header={
          <EntityHeader
            eyebrow="Client · Company"
            title="Tech Solutions Inc."
            avatar={{ name: "Tech Solutions Inc.", radius: "md" }}
            status={{ label: "Active", tone: "success" }}
            facts={[
              { label: "Balance", value: "$12,480" },
              { label: "Tax ID", value: "12-3456789", copyValue: "12-3456789" },
            ]}
          />
        }
        mobileAside="tab"
        labels={{ asideRegion: "Client details" }}
        aside={
          <>
            <DetailSection title="Details" icon={<Info />} onEdit={() => {}}>
              <p className="text-sm">Tax ID 12-3456789 · Chicago · paid in 30 days</p>
            </DetailSection>
            <DetailSection title="Files" icon={<FolderOpen />} count={3} collapsible>
              <p className="text-sm">Contract 2026.pdf</p>
            </DetailSection>
          </>
        }
      >
        <DetailSection
          title="Activity"
          icon={<Activity />}
          count={8}
          actions={[
            { label: "Edit", icon: <Pencil /> },
            { label: "Delete", icon: <Trash2 />, tone: "danger" },
          ]}
        >
          <p className="text-sm">Invoice #2026-0412 issued today, 4:35 PM.</p>
        </DetailSection>
        <DetailSection title="Invoices" icon={<Receipt />} empty labels={{ empty: "No invoices issued" }} />
      </DetailLayout>
    </div>
  )
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `header?` | `ReactNode` |  | The top of the page, usually an EntityHeader. Nothing is reserved when it is left out. |
| `children?` | `ReactNode` |  | The main column, usually DetailSection cards (activity, notes, invoices). |
| `aside?` | `ReactNode` |  | The side panel's content (details, contacts, files). A <DetailAside> passed here is used as the panel itself, so it can carry its own props. None: a single column at maxWidth. |
| `asidePosition?` | `"end" \| "start"` | `"end"` | Which side the panel takes from the breakpoint. The DOM stays main then aside, so reading and Tab order never change; start only reorders the grid. |
| `asideWidth?` | `number` | `360` | The side panel's width in px once the layout is 960px wide; with breakpoint md it is 300px between 720 and 960px. |
| `asideSticky?` | `boolean` | `true` | The panel sticks under the top bar (--topbar-offset), the sticky header and --detail-sticky-offset (room for a bar that pins itself, like EntityHeader's 49px condensed bar) while the main column scrolls, and scrolls inside itself when it is taller than the viewport (--detail-viewport-height when the page scrolls inside a box). |
| `stickyHeader?` | `boolean` | `false` | The header sticks at the top on a glass bar (use it when the header has no condensed bar of its own) and writes its height to --detail-header-height, so the panel and the phone tabs stick under it. |
| `breakpoint?` | `"md" \| "lg"` | `"lg"` | Where the two columns start, measured on the layout's own width (a container query, so it works beside an app sidebar or in a panel): lg at 960px of content (a 1024px screen minus gutters; records need the width) or md at 720px, with a 300px panel up to 960px. |
| `mobileAside?` | `"below" \| "above" \| "tab"` | `"below"` | Below the breakpoint the panel goes after the main column, before it, or into a second tab (a sticky segmented control with labels.main / labels.aside; the hidden pane stays mounted). |
| `gap?` | `"sm" \| "md" \| "lg"` | `"md"` | 16 / 24 / 32px between the columns, the header and the main sections; the panel's cards sit a step closer. |
| `maxWidth?` | `"none" \| "lg" \| "xl" \| "2xl"` | `"xl"` | The content width, centered: max-w-5xl / 6xl / 7xl, or the full width. The page provides the side gutters. |
| `sectionVariant?` | `"surface" \| "outline" \| "plain"` | `"surface"` | The default look of every DetailSection inside: a raised panel, a hairline outline, or plain on the page with a top hairline. A section's own variant wins. |
| `loading?` | `boolean` | `false` | Every DetailSection inside shows its skeleton (unless it sets loading itself), then fades into its content. |
| `tab?` | `"main" \| "aside"` |  | The controlled phone tab (mobileAside="tab"). |
| `defaultTab?` | `"main" \| "aside"` | `"main"` | The phone tab to start on while uncontrolled (mobileAside="tab"). |
| `onTabChange?` | `(tab: DetailLayoutTab) => void` |  | Called when the phone tab changes. |
| `labels?` | `Partial<DetailLayoutLabels>` |  | main ("Activity") and aside ("Details") name the phone tabs, asideRegion ("Details") names the <aside> landmark, tabs ("Page sections") names the tab group. English defaults in DEFAULT_DETAIL_LAYOUT_LABELS. |
| `classNames?` | `Partial<Record<"header" \| "body" \| "main" \| "aside" \| "tabs", string>>` |  | Extra classes for the header wrapper, the grid, the main column, the side panel and the phone tab bar (e.g. a negative margin so the glass bars reach the page edge). |
| `className?` | `string` |  | Classes for the root, which also takes the rest of the div props. Nested DetailLayouts are not supported. |

## Parts

### DetailSection (component)

The section card (a <section aria-labelledby> with an <h2>). title, description, icon (16px, muted), count (a number renders a soft badge), actions (a node, or MenuEntry[] for a ⋯ menu that is an action sheet on phones), onEdit (a pencil), collapsible with open / defaultOpen = true / onOpenChange (the whole header row toggles, content stays mounted and find-in-page opens it), variant "surface" | "outline" | "plain" (default: the layout's sectionVariant), padding "none" | "sm" | "md" = "md" (none for tables that bleed to the edges), loading, skeleton, empty, emptyState, footer (a "View all (12)" row), headingLevel 2 | 3 | 4 = 2, labels { edit, expand, collapse, actions, empty, emptyDescription ("" hides the line) }, classNames (header, icon, title, count, description, actions, edit, toggle, body, empty, footer). Reads useDefaults("detail-section"). Props: DetailSectionProps.

### DetailAside (component)

The side column as an <aside aria-label>; pass it as aside when the panel needs its own props. Inside a DetailLayout it follows the layout's position, stickiness and phone tab. Props: ComponentProps<"aside">.

### DEFAULT_DETAIL_LAYOUT_LABELS (constant)

The English layout strings, to spread under a translation. Type: DetailLayoutLabels.

### DEFAULT_DETAIL_SECTION_LABELS (constant)

The English section strings: edit "Edit", expand "Expand", collapse "Collapse", actions "More actions", empty "Nothing here yet", emptyDescription "When there is something to show, it will appear here.". Type: DetailSectionLabels.

## Examples

- **Phone tabs**: mobileAside="tab" in a 390px frame: Activity and Details as two tabs that keep their state.
- **Contact page**: The person on the start side in a 300px column (on top when narrow), activity in the main one.
- **Collapsible sections**: Counts, descriptions and ⋯ menus; the whole header row toggles.
- **Loading**: The layout's loading flag puts every section into its skeleton.
- **Empty sections**: A new prospect: each empty section offers the action that fills it.
- **Sticky header**: A plain page-header sticks through the layout; the side panel sticks under it.

## See also

- [Entity Header](https://wingo-ui.com/components/entity-header) ([Markdown](https://wingo-ui.com/components/entity-header.md))
- [Card](https://wingo-ui.com/components/card) ([Markdown](https://wingo-ui.com/components/card.md))
- [Collapsible](https://wingo-ui.com/components/collapsible) ([Markdown](https://wingo-ui.com/components/collapsible.md))
- [Split View](https://wingo-ui.com/components/split-view) ([Markdown](https://wingo-ui.com/components/split-view.md))
- [Contact card](https://wingo-ui.com/components/contact-card) ([Markdown](https://wingo-ui.com/components/contact-card.md))

---

Canonical: https://wingo-ui.com/components/detail-layout
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
