# Detail Drawer

Show record details in a right-side drawer.

Customer details from a list

[Complete TSX file](/examples/detail-drawer/default.tsx)

```tsx
"use client";

import { ChevronRightIcon } from "lucide-react";
import {
  Avatar,
  AvatarFallback,
  AvatarImage,
} from "@/siteplane/components/avatar";
import { Badge } from "@/siteplane/components/badge";
import { Button } from "@/siteplane/components/button";
import {
  Card,
  CardHeader,
  CardPanel,
  CardTitle,
} from "@/siteplane/components/card";
import {
  DataList,
  DataListItem,
  DataListLabel,
  DataListValue,
} from "@/siteplane/components/data-list";
import {
  DetailDrawer,
  DetailDrawerBody,
  DetailDrawerClose,
  DetailDrawerDescription,
  DetailDrawerFooter,
  DetailDrawerHeader,
  DetailDrawerPopup,
  DetailDrawerTitle,
  DetailDrawerTrigger,
} from "@/siteplane/components/detail-drawer";
import {
  ResourceList,
  ResourceListContent,
  ResourceListDescription,
  ResourceListIndicator,
  ResourceListItem,
  ResourceListMeta,
  ResourceListTitle,
} from "@/siteplane/components/resource-list";
import {
  Timeline,
  TimelineDescription,
  TimelineItem,
  TimelineTitle,
} from "@/siteplane/components/timeline";

const customers = [
  {
    slug: "northstar-studio",
    name: "Northstar Studio",
    initials: "NS",
    description: "Renewal due in 25 days.",
    status: "Active",
    badgeVariant: "success",
    tone: "success",
    since: "March 2024",
    plan: "Business, EUR 1,200 per month",
    renewal: "Oct 31, 2026",
    seats: "12 of 20 in use",
    lastContact: "Today",
    nextStep: "Send the renewal proposal before Friday.",
    contact: {
      name: "Anna Brooks",
      role: "Head of operations",
      initials: "AB",
      image: "https://ui.siteplane.io/avatars/anna.webp",
      email: "anna@northstar.studio",
      phone: "+49 30 5557 0142",
    },
    activity: [
      {
        title: "Renewal proposal drafted",
        dateTime: "2026-10-06T09:12",
        time: "Today, 9:12 AM",
      },
      {
        title: "Invoice INV-1048 paid",
        dateTime: "2026-09-02T14:30",
        time: "Sep 2, 2:30 PM",
      },
      {
        title: "Seats raised from 10 to 12",
        dateTime: "2026-08-18T11:05",
        time: "Aug 18, 11:05 AM",
      },
    ],
  },
  {
    slug: "atlas-studio",
    name: "Atlas Studio",
    initials: "AS",
    description: "Waiting to confirm the next meeting.",
    status: "At risk",
    badgeVariant: "warning",
    tone: "warning",
    since: "January 2025",
    plan: "Team, EUR 480 per month",
    renewal: "Jan 15, 2027",
    seats: "5 of 10 in use",
    lastContact: "Yesterday",
    nextStep: "Call the project lead to agree on a new date.",
    contact: {
      name: "Tom Quinn",
      role: "Project lead",
      initials: "TQ",
      image: "https://ui.siteplane.io/avatars/tom.webp",
      email: "tom@atlas.studio",
      phone: "+49 40 5550 0187",
    },
    activity: [
      {
        title: "Meeting request sent",
        dateTime: "2026-10-05T16:40",
        time: "Yesterday, 4:40 PM",
      },
      {
        title: "Invoice INV-1024 overdue",
        dateTime: "2026-09-29T08:00",
        time: "Sep 29, 8:00 AM",
      },
      {
        title: "Onboarding finished",
        dateTime: "2026-02-10T10:20",
        time: "Feb 10, 10:20 AM",
      },
    ],
  },
  {
    slug: "studio-nord",
    name: "Studio Nord",
    initials: "SN",
    description: "New inquiry from a partner referral.",
    status: "Paused",
    badgeVariant: "secondary",
    tone: "neutral",
    since: "September 2026",
    plan: "Trial, free for 14 days",
    renewal: "Not started",
    seats: "2 of 3 in use",
    lastContact: "Oct 1",
    nextStep: "Share the product overview and pricing.",
    contact: {
      name: "Lea Hartmann",
      role: "Founder",
      initials: "LH",
      image: "https://ui.siteplane.io/avatars/lea.webp",
      email: "lea@studio-nord.de",
      phone: "+49 89 5551 0132",
    },
    activity: [
      {
        title: "Trial paused at their request",
        dateTime: "2026-10-01T13:15",
        time: "Oct 1, 1:15 PM",
      },
      {
        title: "Partner referral received",
        dateTime: "2026-09-21T09:30",
        time: "Sep 21, 9:30 AM",
      },
    ],
  },
] as const;

function Detail({ label, value }: { label: string; value: string }) {
  return (
    <DataListItem>
      <DataListLabel>{label}</DataListLabel>
      <DataListValue>{value}</DataListValue>
    </DataListItem>
  );
}

export default function Example() {
  return (
    <ResourceList className="w-full max-w-md" variant="soft">
      {customers.map((customer) => (
        <DetailDrawer key={customer.slug}>
          <DetailDrawerTrigger render={<ResourceListItem interactive />}>
            <ResourceListIndicator
              label={customer.status}
              shape="dot"
              tone={customer.tone}
            />
            <ResourceListContent>
              <ResourceListTitle>{customer.name}</ResourceListTitle>
              <ResourceListDescription>
                {customer.description}
              </ResourceListDescription>
            </ResourceListContent>
            <ResourceListMeta className="inline-flex items-center gap-2">
              {customer.lastContact}
              <ChevronRightIcon className="size-4 opacity-60" />
            </ResourceListMeta>
          </DetailDrawerTrigger>
          <DetailDrawerPopup showCloseButton>
            <DetailDrawerHeader>
              <div className="flex items-center gap-3">
                <Avatar className="size-10">
                  <AvatarFallback>{customer.initials}</AvatarFallback>
                </Avatar>
                <div className="grid min-w-0 gap-0.5">
                  <div className="flex min-w-0 flex-wrap items-center gap-2">
                    <DetailDrawerTitle>{customer.name}</DetailDrawerTitle>
                    <Badge variant={customer.badgeVariant}>
                      {customer.status}
                    </Badge>
                  </div>
                  <DetailDrawerDescription>
                    Customer since {customer.since}
                  </DetailDrawerDescription>
                </div>
              </div>
            </DetailDrawerHeader>
            <DetailDrawerBody className="grid content-start gap-4">
              <Card variant="header">
                <CardHeader>
                  <CardTitle render={<h3 />}>Account</CardTitle>
                </CardHeader>
                <CardPanel>
                  <DataList className="[--data-list-label-width:7rem]">
                    <Detail label="Plan" value={customer.plan} />
                    <Detail label="Renewal" value={customer.renewal} />
                    <Detail label="Seats" value={customer.seats} />
                    <Detail label="Next step" value={customer.nextStep} />
                  </DataList>
                </CardPanel>
              </Card>
              <Card variant="header">
                <CardHeader>
                  <CardTitle render={<h3 />}>Primary contact</CardTitle>
                </CardHeader>
                <CardPanel className="grid gap-4">
                  <div className="flex items-center gap-3">
                    <Avatar>
                      <AvatarImage
                        alt={customer.contact.name}
                        src={customer.contact.image}
                      />
                      <AvatarFallback>
                        {customer.contact.initials}
                      </AvatarFallback>
                    </Avatar>
                    <div className="grid min-w-0 text-sm">
                      <span className="font-medium">
                        {customer.contact.name}
                      </span>
                      <span className="text-muted-foreground">
                        {customer.contact.role}
                      </span>
                    </div>
                  </div>
                  <DataList className="[--data-list-label-width:7rem]">
                    <Detail label="Email" value={customer.contact.email} />
                    <Detail label="Phone" value={customer.contact.phone} />
                  </DataList>
                </CardPanel>
              </Card>
              <Card variant="header">
                <CardHeader>
                  <CardTitle render={<h3 />}>Recent activity</CardTitle>
                </CardHeader>
                <CardPanel>
                  <Timeline>
                    {customer.activity.map((event) => (
                      <TimelineItem key={event.dateTime}>
                        <TimelineTitle>{event.title}</TimelineTitle>
                        <TimelineDescription>
                          <time dateTime={event.dateTime}>{event.time}</time>
                        </TimelineDescription>
                      </TimelineItem>
                    ))}
                  </Timeline>
                </CardPanel>
              </Card>
            </DetailDrawerBody>
            <DetailDrawerFooter>
              <DetailDrawerClose render={<Button variant="ghost" />}>
                Close
              </DetailDrawerClose>
              <Button render={<a href={`#${customer.slug}`} />}>
                Open account
              </Button>
            </DetailDrawerFooter>
          </DetailDrawerPopup>
        </DetailDrawer>
      ))}
    </ResourceList>
  );
}
```

Pick a customer in the list to open the detail view. The header names the record and its status, and the body groups the account, the primary contact and the recent activity into cards.

## Installation

Complete [one-time setup](/docs/installation/) first.

```bash
pnpm dlx shadcn@4.18.0 add @siteplane/detail-drawer
```

With npm: `npx shadcn@4.18.0 add @siteplane/detail-drawer`. [Registry JSON](/registry/detail-drawer.json) lists source files and dependencies.

For this page’s examples, also install:

```bash
pnpm dlx shadcn@4.18.0 add @siteplane/avatar @siteplane/badge @siteplane/button @siteplane/card @siteplane/data-list @siteplane/form @siteplane/input @siteplane/resource-list @siteplane/table @siteplane/timeline
```

Field is installed with Input.

## Usage

```tsx
import { Button } from "@/siteplane/components/button";
import {
  DetailDrawer,
  DetailDrawerTrigger,
  DetailDrawerPopup,
  DetailDrawerHeader,
  DetailDrawerTitle,
  DetailDrawerDescription,
  DetailDrawerBody,
  DetailDrawerFooter,
  DetailDrawerClose,
} from "@/siteplane/components/detail-drawer";

<DetailDrawer>
  <DetailDrawerTrigger render={<Button variant="outline" />}>
    View account
  </DetailDrawerTrigger>
  <DetailDrawerPopup>
    <DetailDrawerHeader>
      <DetailDrawerTitle>Northstar Studio</DetailDrawerTitle>
      <DetailDrawerDescription>
        Active customer since 2024.
      </DetailDrawerDescription>
    </DetailDrawerHeader>
    <DetailDrawerBody>Details</DetailDrawerBody>
    <DetailDrawerFooter>
      <DetailDrawerClose render={<Button variant="ghost" />}>
        Close
      </DetailDrawerClose>
    </DetailDrawerFooter>
  </DetailDrawerPopup>
</DetailDrawer>;
```

## Composition

```text
DetailDrawer
├── DetailDrawerTrigger
└── DetailDrawerPopup
    ├── DetailDrawerHeader
    │   ├── DetailDrawerTitle
    │   └── DetailDrawerDescription
    ├── DetailDrawerBody
    └── DetailDrawerFooter
        └── DetailDrawerClose
```

## Edit form

Wrap the header, body and footer in a form so Save submits and closes the drawer. `DetailDrawerBody` keeps longer forms scrollable between the header and footer.

Edit project drawer

[Complete TSX file](/examples/detail-drawer/form.tsx)

```tsx
"use client";

import { Button } from "@/siteplane/components/button";
import {
  DetailDrawer,
  DetailDrawerBody,
  DetailDrawerClose,
  DetailDrawerDescription,
  DetailDrawerFooter,
  DetailDrawerHeader,
  DetailDrawerPopup,
  DetailDrawerTitle,
  DetailDrawerTrigger,
} from "@/siteplane/components/detail-drawer";
import { Field, FieldError, FieldLabel } from "@/siteplane/components/field";
import { Form } from "@/siteplane/components/form";
import { Input } from "@/siteplane/components/input";
import { useState } from "react";

export default function Example() {
  const [open, setOpen] = useState(false);
  return (
    <DetailDrawer open={open} onOpenChange={setOpen}>
      <DetailDrawerTrigger render={<Button variant="outline" />}>
        Edit project
      </DetailDrawerTrigger>
      <DetailDrawerPopup>
        <Form
          className="flex min-h-0 flex-1 flex-col"
          onSubmit={(event) => {
            event.preventDefault();
            setOpen(false);
          }}
        >
          <DetailDrawerHeader>
            <DetailDrawerTitle>Edit project</DetailDrawerTitle>
            <DetailDrawerDescription>
              Changes are visible to everyone in the workspace.
            </DetailDrawerDescription>
          </DetailDrawerHeader>
          <DetailDrawerBody className="grid content-start gap-4">
            <Field name="name">
              <FieldLabel>Project name</FieldLabel>
              <Input defaultValue="Website Redesign" required />
              <FieldError match="valueMissing">
                Enter a project name.
              </FieldError>
            </Field>
            <Field name="owner">
              <FieldLabel>Owner</FieldLabel>
              <Input defaultValue="Ben Carter" required />
              <FieldError match="valueMissing">Enter an owner.</FieldError>
            </Field>
          </DetailDrawerBody>
          <DetailDrawerFooter>
            <DetailDrawerClose render={<Button variant="ghost" />}>
              Cancel
            </DetailDrawerClose>
            <Button type="submit">Save changes</Button>
          </DetailDrawerFooter>
        </Form>
      </DetailDrawerPopup>
    </DetailDrawer>
  );
}
```

## Scrollable content

`DetailDrawerBody` scrolls long record content while the header and footer stay visible. This example lists the invoices of a customer: summary cards on top, then a [Table](/docs/components/table/) in a card with a status badge, an amount aligned to the end with tabular figures, and a download action in every row.

Invoice history with downloads

[Complete TSX file](/examples/detail-drawer/scrolling.tsx)

```tsx
"use client";

import { DownloadIcon } from "lucide-react";
import { Badge } from "@/siteplane/components/badge";
import { Button } from "@/siteplane/components/button";
import { Card, CardPanel } from "@/siteplane/components/card";
import {
  DetailDrawer,
  DetailDrawerBody,
  DetailDrawerClose,
  DetailDrawerDescription,
  DetailDrawerFooter,
  DetailDrawerHeader,
  DetailDrawerPopup,
  DetailDrawerTitle,
  DetailDrawerTrigger,
} from "@/siteplane/components/detail-drawer";
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/siteplane/components/table";

const invoices = [
  { number: "INV-1060", date: "2026-10-01", amount: 1200, status: "Open" },
  { number: "INV-1048", date: "2026-09-01", amount: 1200, status: "Paid" },
  { number: "INV-1036", date: "2026-08-01", amount: 1200, status: "Paid" },
  { number: "INV-1024", date: "2026-07-01", amount: 1200, status: "Overdue" },
  { number: "INV-1011", date: "2026-06-01", amount: 950, status: "Paid" },
  { number: "INV-0998", date: "2026-05-01", amount: 950, status: "Paid" },
  { number: "INV-0985", date: "2026-04-01", amount: 950, status: "Paid" },
  { number: "INV-0971", date: "2026-03-01", amount: 950, status: "Paid" },
  { number: "INV-0958", date: "2026-02-01", amount: 950, status: "Paid" },
  { number: "INV-0944", date: "2026-01-01", amount: 950, status: "Paid" },
  { number: "INV-0931", date: "2025-12-01", amount: 720, status: "Paid" },
  { number: "INV-0917", date: "2025-11-01", amount: 720, status: "Paid" },
  { number: "INV-0903", date: "2025-10-01", amount: 720, status: "Paid" },
  { number: "INV-0890", date: "2025-09-01", amount: 720, status: "Paid" },
] as const;

const statusVariants = {
  Open: "info",
  Overdue: "error",
  Paid: "success",
} as const;

const euro = new Intl.NumberFormat("en-US", {
  currency: "EUR",
  style: "currency",
});
const day = new Intl.DateTimeFormat("en-US", {
  day: "numeric",
  month: "short",
  timeZone: "UTC",
  year: "numeric",
});

const total = (list: readonly { amount: number }[]) =>
  list.reduce((sum, invoice) => sum + invoice.amount, 0);
const paid = invoices.filter(
  (invoice) => invoice.status === "Paid" && invoice.date.startsWith("2026"),
);
const outstanding = invoices.filter((invoice) => invoice.status !== "Paid");
const overdue = outstanding.filter((invoice) => invoice.status === "Overdue");

export default function Example() {
  return (
    <DetailDrawer>
      <DetailDrawerTrigger render={<Button variant="outline" />}>
        View invoices
      </DetailDrawerTrigger>
      <DetailDrawerPopup showCloseButton>
        <DetailDrawerHeader>
          <DetailDrawerTitle>Invoices</DetailDrawerTitle>
          <DetailDrawerDescription>
            Northstar Studio · billed monthly
          </DetailDrawerDescription>
        </DetailDrawerHeader>
        <DetailDrawerBody className="grid content-start gap-4">
          <div className="grid grid-cols-2 gap-4">
            <Card>
              <CardPanel className="grid gap-1">
                <span className="text-muted-foreground text-sm">
                  Paid in 2026
                </span>
                <span className="font-semibold text-xl tabular-nums">
                  {euro.format(total(paid))}
                </span>
                <span>
                  <Badge variant="secondary">{paid.length} invoices</Badge>
                </span>
              </CardPanel>
            </Card>
            <Card>
              <CardPanel className="grid gap-1">
                <span className="text-muted-foreground text-sm">
                  Outstanding
                </span>
                <span className="font-semibold text-xl tabular-nums">
                  {euro.format(total(outstanding))}
                </span>
                <span>
                  <Badge variant="error">{overdue.length} overdue</Badge>
                </span>
              </CardPanel>
            </Card>
          </div>
          <Card className="overflow-hidden [--table-edge-padding:--spacing(5)]">
            <Table aria-label="Invoices" size="sm">
              <TableHeader>
                <TableHead id="invoice" isRowHeader>
                  Invoice
                </TableHead>
                <TableHead id="status">Status</TableHead>
                <TableHead id="amount" align="end">
                  Amount
                </TableHead>
                <TableHead id="download" align="end">
                  <span className="sr-only">Download</span>
                </TableHead>
              </TableHeader>
              <TableBody>
                {invoices.map((invoice) => (
                  <TableRow
                    id={invoice.number}
                    key={invoice.number}
                    textValue={invoice.number}
                  >
                    <TableCell>
                      <div className="grid">
                        <span className="font-medium">{invoice.number}</span>
                        <span className="text-muted-foreground">
                          {day.format(new Date(invoice.date))}
                        </span>
                      </div>
                    </TableCell>
                    <TableCell>
                      <Badge variant={statusVariants[invoice.status]}>
                        {invoice.status}
                      </Badge>
                    </TableCell>
                    <TableCell align="end" className="tabular-nums">
                      {euro.format(invoice.amount)}
                    </TableCell>
                    <TableCell align="end">
                      <Button
                        aria-label={`Download ${invoice.number}`}
                        size="icon-sm"
                        variant="ghost"
                      >
                        <DownloadIcon />
                      </Button>
                    </TableCell>
                  </TableRow>
                ))}
              </TableBody>
            </Table>
          </Card>
        </DetailDrawerBody>
        <DetailDrawerFooter>
          <DetailDrawerClose render={<Button variant="ghost" />}>
            Close
          </DetailDrawerClose>
          <Button>
            <DownloadIcon />
            Download all
          </Button>
        </DetailDrawerFooter>
      </DetailDrawerPopup>
    </DetailDrawer>
  );
}
```

## API reference

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| DetailDrawer.position | right \| left \| top \| bottom | right | Overrides Drawer’s bottom default. |
| DetailDrawerPopup.position | right \| left \| top \| bottom | root position | Popup edge. Leave it unset, so the content follows the position of DetailDrawer, which also sets the swipe direction. |
| DetailDrawerPopup.variant | default \| straight \| inset | straight | Straight side panel. |
| DetailDrawerPopup.className | string | max-w-md plus supplied classes | Maximum width. |
| DetailDrawerBody.className | string | min-h-0 flex-1 plus supplied classes | Flexible scrolling body. |

All inherited props and defaults are those of [Drawer](/docs/components/drawer/). DetailDrawer wraps Drawer; Popup wraps DrawerPopup; Header wraps DrawerHeader; Body wraps DrawerPanel; Footer wraps DrawerFooter. Trigger, Close, Title and Description are direct aliases to Drawer’s parts. Popup takes its position from the root, so set `position` once on DetailDrawer; its `variant` default can be overridden. Body inherits scrollFade=true, scrollable=true and allowSelection=true; Footer inherits allowSelection=true; Header inherits allowSelection=false. Popup inherits showCloseButton=false and showBar=false. The component supplies no selected-record, fetching, saving or error API.

`DetailDrawerContent` is the earlier name of `DetailDrawerPopup`. It is the same part and keeps working until 2.0; write `DetailDrawerPopup` in new code.

`open`/`defaultOpen` choose controlled/initial state (closed by default); `onOpenChange(open, eventDetails)` requests a transition and `onOpenChangeComplete(open)` reports motion completion. Initial/final focus and dismissal behavior belong to the underlying foundation.

[Read the installed source](/source/detail-drawer).

## Usage notes

Provide a title, clear close action and meaningful focus target. Escape and focus restoration follow the foundation. Application loading, errors and async confirmation must be represented explicitly in the content. Do not treat swipe dismissal as the only way to close.
