Siteplane UI

Detail Drawer

Show record details in a right-side drawer.

Markdown
"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

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

Complete the one-time setup first. Registry JSON lists source files and dependencies.

For this page’s examples, also install:

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

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

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.

"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 in a card with a status badge, an amount aligned to the end with tabular figures, and a download action in every row.

"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

Component properties
PropTypeDefaultDescription
DetailDrawer.positionright | left | top | bottomrightOverrides Drawer’s bottom default.
DetailDrawerPopup.positionright | left | top | bottomroot positionPopup edge. Leave it unset, so the content follows the position of DetailDrawer, which also sets the swipe direction.
DetailDrawerPopup.variantdefault | straight | insetstraightStraight side panel.
DetailDrawerPopup.classNamestringmax-w-md plus supplied classesMaximum width.
DetailDrawerBody.classNamestringmin-h-0 flex-1 plus supplied classesFlexible scrolling body.

All inherited props and defaults are those of 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.

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.