Brik Design System
Components

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 on viewDetailsAction — 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.

The header composes four optional pieces:

  • subtitle — uppercase label-tier line above the title (entity type, parent record). Renders in text-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 edge
  • left — for navigation or sidebar panels
  • bottom — 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 over secondaryAction

Body composition

Inside a Sheet body, reach for the sheet primitive set first. Don't invent ad-hoc markup.

PrimitiveUse for
SheetSectionNamed section wrapper. One per logical grouping.
FieldLabel + value pair.
FieldGrid2/3/4-column grid of Fields or Cards.
TagGroupTag clusters inside a Field.
BulletListShort-item lists.
Card / CardListEntity rows.
TableTabular data.
AccordionCollapsible groups when a section has many rows.
EmptyStateWhole-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.

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.

RolePrimitiveRenders
Sheet titleSheet.title<h2>, one per sheet
Sheet subtitleSheet.subtitle<p>, small and muted above the title
Section headingSheetSection.headinguppercase <h3> label — the only section heading inside a sheet body
Section descriptionSheetSection.descriptionoptional lead <p> under the heading
Field labelField.labelshort, compact text
Field valueField childrenany 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 recordThe user's primary task is drilling into one facet
Navigation context is valuable and a modal would lose itThe parent page stays useful while the overlay is open
Every section is roughly equally importantOne section is the focus; others are in the sheet because they're long
Editing happens on many sections in a sessionEditing is occasional and per-section

When to use which treatment

ScenarioVariantFooter
Read-only metadata, no CTAfloatingnone
Read detail with edit capabilitydefault, mode="read" + onEdit[Close] [Edit] (auto)
Active form / edit workflowdefault, mode="edit" + onSave[Cancel] [Save] (auto)
Read + ancillary actionmode="read" + onEdit + secondaryAction[Refresh] . . . [Close] [Edit]
Record with provenance / historyany mode + tabsmode-driven
Non-standard action setanycustom 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 onBack push-stack navigation — Sheets stack semantically, not visually.
  • Don't use DataSection inside 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.heading is the entire API.
  • Don't build a ParagraphField or ListField. Field accepts any ReactNode value — paragraph-label and list-label are usages, not components. See Naming Principles.
  • Don't place a Divider between SheetSections. The inter-section rhythm is built in.
  • Don't hide a SheetSection heading behind an empty heading prop to get a "headingless" look — use description or plain children instead.

Accessibility

  • Renders role="dialog" with aria-labelledby linking the title.
  • Focus trap inside the sheet; focus returns to the trigger on close.
  • Escape closes (default closeOnEscape={true}); backdrop click closes (default closeOnBackdrop={true} — no effect on floating variant since there's no backdrop).
  • Auto-footer Save button announces aria-busy while saveLoading={true}.

API

PropTypeDefault
isOpenboolean (required)
onClose() => void (required)
childrenReactNode
side'right' | 'left' | 'bottom''right'
titleReactNode
subtitleReactNode
descriptionReactNode
widthstring'400px'
variant'default' | 'floating''default'
closeOnBackdropbooleantrue
closeOnEscapebooleantrue
showCloseButtonbooleantrue
onBack() => void
mode'read' | 'edit'
editTarget'inline' | 'page''inline'
viewDetailsActionSheetViewDetailsAction
onEdit / onSave / onCancel() => void
editLabel / saveLabel / cancelLabel / closeLabelstringlabel defaults
saveDisabled / saveLoadingbooleanfalse
footerReactNode (overrides auto-footer)
secondaryActionSheetSecondaryAction
tabsSheetTab[]
activeTabstring (controlled)first tab id
onTabChange(tabId: string) => void
densitySheetDensity'comfortable'
loadingbooleanfalse
  • 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

On this page

💬