Sheet
Sliding panel anchored to a screen edge. Read / edit modes, tabs, secondary action, floating variant.
Sheet is the canonical detail-view panel — slides in from a screen edge for record views, edit workflows, and secondary content that shouldn't replace the current page. Mode-driven footer (Read / Edit) auto-renders the right action set; pass tabs for provenance/history surfaces; pair with SheetSection and Field for a locked-down body composition.
Use it for
- Record detail views (company profile, project brief, contact card)
- Edit workflows that compose multi-section forms
- Drilldowns from table rows or dashboard tiles
- Anywhere a Modal would feel too interruptive but the content needs more space than a Popover
Import
import { Sheet, SheetSection } from '@brikdesigns/bds';Modes
Sheet has two modes that map to the most common UX pattern across the product suite:
read— view-only display. Footer (when shown) renders[Close] [View details] [Edit].[View details]is conditional onviewDetailsAction— omit when the sheet has no corresponding full read page (e.g.plans).edit— active form state. Footer renders[Cancel] [Save].
The canonical pattern: open in read, click Edit to switch to edit, Save / Cancel returns to read. Mirrors the renew-pms convention.
View details + edit target
Sheet+page hybrid tables surface three read-mode actions: [Close] · [View details] (navigates to the read page) · [Edit] (navigates to the edit page). Use editTarget="page" to signal that onEdit performs navigation rather than flipping the sheet to in-place edit mode (editTarget="inline", the default, is the plans pattern).
<Sheet
isOpen={open}
onClose={() => setOpen(false)}
subtitle="Service"
title="Brand Identity Bundle"
mode="read"
editTarget="page"
viewDetailsAction={{
label: 'View details',
href: `/settings/services/${slug}`,
}}
onEdit={() => router.push(`/settings/services/${slug}/edit`)}
>
<ServiceSnapshot />
</Sheet>viewDetailsAction.href renders the button as a semantic <a> (Button's native anchor mode). Provide onClick instead when navigation runs through a router helper.
import { useState } from 'react';
function CompanySheet() {
const [open, setOpen] = useState(false);
const [mode, setMode] = useState<'read' | 'edit'>('read');
return (
<Sheet
isOpen={open}
onClose={() => setOpen(false)}
subtitle="Company"
title="Brik Designs"
description="Active · Updated 2 days ago"
mode={mode}
onEdit={() => setMode('edit')}
onSave={async () => { await save(); setMode('read'); }}
onCancel={() => setMode('read')}
>
{mode === 'read' ? <ReadOnlyFields /> : <EditFormFields />}
</Sheet>
);
}Don't put Edit as a button in the body. Use mode="read" + onEdit so it surfaces in the auto-footer where users expect primary actions.
Header
The header composes four optional pieces:
subtitle— uppercase label-tier line above the title (entity type, parent record). Renders intext-muted. ("Eyebrow" is a banned synonym — see Slot Vocabulary → Subtitle.)title— main heading, rendered as<h2>.description— long-form secondary text below the title (record state, timestamps).onBack— back button for nested sheet navigation. Pair with your sheet stack controller.
<Sheet
subtitle="Strategic Brief"
title="Birdwell & Mutlak"
description="Draft · Last edited 3 days ago"
onBack={popSheet}
>
...
</Sheet>Sides
right(default) — slides from right edgeleft— for navigation or sidebar panelsbottom— full-width drawer, mobile-friendly
<Sheet side="bottom">...</Sheet>Variants
default— full-height overlay with backdrop (forms, edit workflows)floating— rounded floating panel with elevation, no backdrop (read-only detail views, inline drill-ins from a table row)
<Sheet variant="floating" mode="read" onEdit={...}>...</Sheet>Tabs
Tabs render below the header. When tabs is supplied, children is ignored — each tab supplies its own content. Use for separating Details from Sources / History / Activity.
<Sheet
title="Birdwell & Mutlak"
tabs={[
{ id: 'details', label: 'Details', content: <DetailsTab /> },
{ id: 'sources', label: 'Sources', content: <SourcesTab /> },
{ id: 'history', label: 'History', content: <HistoryTab /> },
]}
activeTab={tab}
onTabChange={setTab}
/>Keep the first tab's id stable (e.g. 'details') — it's the default active tab when activeTab is uncontrolled.
Secondary action
For ancillary actions next to the primary Edit / Save (e.g. Refresh Brief, Run Extraction), pass secondaryAction instead of composing a custom footer.
<Sheet
mode="read"
onEdit={() => setMode('edit')}
secondaryAction={{
label: 'Refresh brief',
onClick: handleRefresh,
icon: <RefreshIcon />,
}}
>
...
</Sheet>Behavior:
- Read mode — renders
[Secondary] . . . [Close] [Edit] - Edit mode — secondary action is suppressed (Save's commit surface stays unambiguous)
- Custom
footer— wins oversecondaryAction
Body composition
Inside a Sheet body, reach for the sheet primitive set first. Don't invent ad-hoc markup.
| Primitive | Use for |
|---|---|
| SheetSection | Named section wrapper. One per logical grouping. |
| Field | Label + value pair. |
| FieldGrid | 2/3/4-column grid of Fields or Cards. |
| TagGroup | Tag clusters inside a Field. |
| BulletList | Short-item lists. |
| Card / CardList | Entity rows. |
| Table | Tabular data. |
| Accordion | Collapsible groups when a section has many rows. |
| EmptyState | Whole-section "no data" treatment. |
Don't reach for raw <h3> / <table> / <ul> inside a Sheet body — each has a primitive that locks the type scale and spacing. Use SheetSection for section headings and Field for inline labels and values.
Composing a read-mode sheet
A read-mode sheet is the narrow-view sibling of the Read-Mode Page pattern — where a page surfaces an entire record at once, a sheet surfaces one facet of it. The composition is always the same four primitives: Sheet, SheetSection, FieldGrid, and Field. DataSection never appears inside a Sheet — it's page-scoped and carries page-tier title typography that overwhelms the narrow view; SheetSection is its sheet-side sibling.
Three conventions cover how the read ↔ edit lifecycle attaches to that composition.
In-sheet toggle
The sheet opens in whichever mode the caller specifies, and a [View] / [Edit] ButtonGroup in the sheet header flips between modes without closing. Use it for read-first traffic, where the record has a meaningful read-mode (formatted values, links, pills) richer than the form, and where the page-side DataSection entry should honor the user's click intent.
const { mode, headerActions, footer } = useEditableSheetConfig({ initialMode });
<Sheet title="Identity" subtitle="Company" headerActions={headerActions}>
{mode === 'view' ? <IdentityView data={company} /> : <IdentityForm data={company} />}
{footer}
</Sheet>useEditableSheetConfig owns the footer — it auto-renders [Close] / [Edit] in view mode and [Cancel] / [Save] in edit mode. Don't hand-roll it.
Edit-only sheet
The sheet always opens in edit mode — no toggle, no read-mode rendering. Use it when the parent page's read-mode view already shows every field the user needs (e.g. a DataSection on the Overview tab), when editing is the only reason to open the sheet, or when the form is short (1–3 fields) and a mode toggle would be more friction than value.
<Sheet title="Notes" subtitle="Company">
<SheetSection heading="Internal notes">
<TextArea label="Notes" fullWidth rows={10} value={notes} onChange={handleChange} />
</SheetSection>
</Sheet>The page-side entry is a single Button in the DataSection.actions slot, not a ButtonGroup.
Footer-driven navigation
The canonical pattern for table-row sheets in catalog admins (services, offerings, service lines, industry pages). The sheet opens in mode="read" from a [View] action in a table row, shows a snapshot of core fields, and the footer's [View details] navigates to the full read page while [Edit] navigates to the edit page (see View details + edit target above). Read-only catalog tables drop both viewDetailsAction and onEdit — the row navigates straight to the read page.
The editTarget prop documents which pattern is in play ('page' navigates, 'inline' flips the sheet in place) — data-edit-target on the sheet root lets tests assert consumer intent.
Typography roles
SheetSection owns all section-level typography — don't introduce alternatives.
| Role | Primitive | Renders |
|---|---|---|
| Sheet title | Sheet.title | <h2>, one per sheet |
| Sheet subtitle | Sheet.subtitle | <p>, small and muted above the title |
| Section heading | SheetSection.heading | uppercase <h3> label — the only section heading inside a sheet body |
| Section description | SheetSection.description | optional lead <p> under the heading |
| Field label | Field.label | short, compact text |
| Field value | Field children | any ReactNode; empty renders "Not set" automatically |
Section spacing
The vertical gap between consecutive SheetSections is SheetSection's own concern — tune it with the spacing prop (md or lg) rather than inline marginTop, custom flex gaps, or spacer <div>s.
.bds-sheet-section + .bds-sheet-section { margin-top: var(--padding-lg); }
.bds-sheet-section--spacing-lg + .bds-sheet-section { margin-top: var(--padding-xl); }Sheet vs page
Both Read-Mode Page and a read-mode sheet compose the same atomic primitives (FieldGrid, Field, BulletList) — the wrapper is the decision.
Choose a page (DataSection) when… | Choose a sheet (SheetSection) when… |
|---|---|
| The user's primary task is scanning a whole record | The user's primary task is drilling into one facet |
| Navigation context is valuable and a modal would lose it | The parent page stays useful while the overlay is open |
| Every section is roughly equally important | One section is the focus; others are in the sheet because they're long |
| Editing happens on many sections in a session | Editing is occasional and per-section |
When to use which treatment
| Scenario | Variant | Footer |
|---|---|---|
| Read-only metadata, no CTA | floating | none |
| Read detail with edit capability | default, mode="read" + onEdit | [Close] [Edit] (auto) |
| Active form / edit workflow | default, mode="edit" + onSave | [Cancel] [Save] (auto) |
| Read + ancillary action | mode="read" + onEdit + secondaryAction | [Refresh] . . . [Close] [Edit] |
| Record with provenance / history | any mode + tabs | mode-driven |
| Non-standard action set | any | custom footer |
When not to use
- Don't use Sheet for short confirmations. Use Modal preset="confirm".
- Don't use Sheet without a clear reason to leave the page. If the content fits inline, render it on the page directly.
- Don't nest Sheets visually. Use
onBackpush-stack navigation — Sheets stack semantically, not visually. - Don't use
DataSectioninside a Sheet. It's page-scoped and carries page-tier title typography that overwhelms the narrow view — see Composing a read-mode sheet. - Don't invent a custom "SectionHeading" component.
SheetSection.headingis the entire API. - Don't build a
ParagraphFieldorListField.Fieldaccepts anyReactNodevalue — paragraph-label and list-label are usages, not components. See Naming Principles. - Don't place a
DividerbetweenSheetSections. The inter-section rhythm is built in. - Don't hide a
SheetSectionheading behind an emptyheadingprop to get a "headingless" look — usedescriptionor plain children instead.
Accessibility
- Renders
role="dialog"witharia-labelledbylinking the title. - Focus trap inside the sheet; focus returns to the trigger on close.
Escapecloses (defaultcloseOnEscape={true}); backdrop click closes (defaultcloseOnBackdrop={true}— no effect onfloatingvariant since there's no backdrop).- Auto-footer Save button announces
aria-busywhilesaveLoading={true}.
API
| Prop | Type | Default |
|---|---|---|
isOpen | boolean (required) | — |
onClose | () => void (required) | — |
children | ReactNode | — |
side | 'right' | 'left' | 'bottom' | 'right' |
title | ReactNode | — |
subtitle | ReactNode | — |
description | ReactNode | — |
width | string | '400px' |
variant | 'default' | 'floating' | 'default' |
closeOnBackdrop | boolean | true |
closeOnEscape | boolean | true |
showCloseButton | boolean | true |
onBack | () => void | — |
mode | 'read' | 'edit' | — |
editTarget | 'inline' | 'page' | 'inline' |
viewDetailsAction | SheetViewDetailsAction | — |
onEdit / onSave / onCancel | () => void | — |
editLabel / saveLabel / cancelLabel / closeLabel | string | label defaults |
saveDisabled / saveLoading | boolean | false |
footer | ReactNode (overrides auto-footer) | — |
secondaryAction | SheetSecondaryAction | — |
tabs | SheetTab[] | — |
activeTab | string (controlled) | first tab id |
onTabChange | (tabId: string) => void | — |
density | SheetDensity | 'comfortable' |
loading | boolean | false |
Related
- SheetSection — body section wrapper
- Field — locked label/value primitive for sheet bodies (folded in the former Sheet typography primitives)
- Modal — interrupting-overlay alternative
- Popover — anchored-floating alternative
- Storybook playground