Skip to content
Component installs need the registry setup
VegaStack Design

Record Layout

A record page's main column beside a sticky right rail of cards, with a Details sheet in its place on small screens.

Status
stable
Since
0.23.15
Accessibility pattern
complementary landmark, dialog on small screens

Last updated

Weekly sync with Acme

The team agreed the rollout plan and the next review date.

Install

Add Record Layout from the VegaStack registry. The CLI verifies the item's integrity hash before writing it.

pnpm dlx shadcn@latest add @vegastack/record-layout

The same command installs the registry items it composes: @vegastack/button, @vegastack/sheet, @vegastack/skeleton.

Usage

import {
  RecordDetailsSheet,
  RecordLayout,
  RecordLayoutMain,
  RecordLayoutRail,
} from "@/components/ui/record-layout";

<RecordLayout>
  <RecordLayoutMain>
    <PageHeader
      title="Weekly sync"
      actions={<RecordDetailsSheet>{facts}</RecordDetailsSheet>}
    />
    <Tabs>…</Tabs>
  </RecordLayoutMain>
  <RecordLayoutRail aria-label="Details">
    <Card size="sm">…</Card>
  </RecordLayoutRail>
</RecordLayout>;

RecordLayout is a record page's frame: the main column (title, status line, tabs) and, from lg up, a 320px right rail of cards — a PropertyList of the record's facts first, related work below. The rail sticks to the top of the scroll container and scrolls on its own when it is taller than the viewport; set --record-rail-offset to the height above it. Below lg the rail is hidden, and RecordDetailsSheet — a ghost ⓘ icon button beside the title — opens the same facts in a Sheet from the right, full width on a phone. Anything else from the rail moves into the main column, usually as a tab.

The switch is CSS, so the layout is server-safe. When a rail card holds state that must exist only once (a selection, pending writes), mount it in one place with useMediaQuery.

Weekly sync with Acme

The team agreed the rollout plan and the next review date.

Anatomy

RecordLayout — data-slot="record-layout"
RecordLayoutMain — data-slot="record-layout-main"
RecordLayoutRail — data-slot="record-layout-rail"
RecordDetailsSheet
RecordLayoutPanels — data-slot="record-layout-panels"
RecordTabCount — data-slot="record-tab-count"
RecordLayoutMainSkeleton — data-slot="record-layout-main-skeleton"
RecordTabsRow — data-slot="record-tabs-row"
RecordTabsActions — data-slot="record-tabs-actions"

Examples

Actions in a tab on small screens

A rail card that holds state — suggestions being selected, writes waiting on Undo — mounts once: in the rail from lg up, else as the last tab of the main column.

const wide = useMediaQuery("(min-width: 1024px)");
const actions = <MeetingActions … />;

<RecordLayout>
  <RecordLayoutMain>
    <Tabs>
      <TabsList>
        <TabsTrigger value="summary">Summary</TabsTrigger>
        {!wide && <TabsTrigger value="actions">Actions</TabsTrigger>}
      </TabsList>
      {!wide && <TabsContent value="actions">{actions}</TabsContent>}
    </Tabs>
  </RecordLayoutMain>
  {wide && <RecordLayoutRail aria-label="Details and actions">{actions}</RecordLayoutRail>}
</RecordLayout>;

Tabs, counts and loading

RecordTabsRow puts the TabsList and the tab's icon actions (RecordTabsActions: Copy, Edit) on one row at the main column's width. The rail follows the layout's own width (a container query at 1024px), not the viewport; useRecordLayoutWide() gives the same answer to script, for mounting rail content once. RecordLayoutPanels holds the main column's TabsContent panels under the tab row (spread useTabsSwipe() onto it), RecordTabCount puts a muted count in a TabsTrigger, and RecordLayoutMainSkeleton stands in for the main column while the record loads.

<Tabs value={tab} onValueChange={setTab}>
  <RecordTabsRow>
    <TabsList>
      <TabsTrigger value="summary">Summary</TabsTrigger>
      <TabsTrigger value="actions">
        Actions <RecordTabCount count={3} label="to review" />
      </TabsTrigger>
    </TabsList>
    <RecordTabsActions>{/* Copy, Edit */}</RecordTabsActions>
  </RecordTabsRow>
  <RecordLayoutPanels {...swipe}>
    <TabsContent value="summary">…</TabsContent>
  </RecordLayoutPanels>
</Tabs>

API Reference

PropTypeDefaultDescription
children*React.ReactNode—What the sheet shows — usually the same PropertyList as the rail's first card.
classNamestring—Classes for the ⓘ trigger button.
onOpenChange((open: boolean) => void)—Called when the sheet opens or closes.
openboolean—Controlled open state.
titlestring"Details"The sheet's title, and the trigger's accessible name.

RecordLayout, RecordLayoutMain and RecordLayoutRail take their element's native props.

Accessibility

  • The rail is an aside (a complementary landmark): name it with aria-label.
  • The ⓘ trigger is named by title ("Details"); the sheet is a modal dialog titled the same, and focus returns to the trigger when it closes.
KeyAction
Enter / SpaceOpen the Details sheet
EscClose the sheet
ContractStates tested
Behaviourrail, details-sheet
Accessibilitylabeled, browser-accessibility-test
Visualdefault

Do / Don't

Do
Put the record's facts first in the rail and the same facts in the Details sheet.
Don't
Use the rail for navigation or page actions — those belong in the sidebar and the top bar.

On this page