Skip to content
UI

Record Sheet

beta

The sheet that opens one record from a list, in four bands every module reads the same way.

@zenoxai/ui/patterns/record-sheet Markdown
function RecordSheetDemo() {
  const [index, setIndex] = React.useState(0)
  const statuses = [
    { value: "new", label: "New" },
    { value: "pending", label: "Pending" },
    { value: "resolved", label: "Resolved" },
    { value: "false-positive", label: "False positive" },
  ]

  return (
    <Sheet>
      <SheetTrigger render={<Button variant="outline" />}>
        Open the leaked credential
      </SheetTrigger>
      <RecordSheetContent>
        <RecordSheetHeader
          severity="high"
          icon={<FileKeyIcon className="text-sev-high-fg" />}
          title="Email domain leak monitoring"
          meta={
            <>
              <StatusBadge tone="neutral" icon={CircleDotIcon} label="New" />
              <SeverityBadge level="high" />
              <span className="inline-flex items-center gap-1.5">
                <CalendarIcon />
                10/14/2026 21:36:26
              </span>
            </>
          }
          position={`${index + 1} of 4`}
          onPrevious={index > 0 ? () => setIndex(index - 1) : undefined}
          onNext={index < 3 ? () => setIndex(index + 1) : undefined}
          link="https://app.zenox.example/identity/credentials/fff187d3"
        />
        <RecordSheetTriage>
          <Field>
            <FieldLabel htmlFor="credential-status">Status</FieldLabel>
            <Select items={statuses} defaultValue="new">
              <SelectTrigger id="credential-status" className="w-full">
                <SelectValue />
              </SelectTrigger>
              <SelectContent>
                {statuses.map((status) => (
                  <SelectItem key={status.value} value={status.value}>
                    {status.label}
                  </SelectItem>
                ))}
              </SelectContent>
            </Select>
          </Field>
          <Field>
            <FieldLabel htmlFor="credential-assignee">Assignee</FieldLabel>
            <Select
              items={[{ value: "none", label: "Unassigned" }]}
              defaultValue="none"
            >
              <SelectTrigger id="credential-assignee" className="w-full">
                <SelectValue />
              </SelectTrigger>
              <SelectContent>
                <SelectItem value="none">Unassigned</SelectItem>
              </SelectContent>
            </Select>
          </Field>
        </RecordSheetTriage>
        <RecordSheetTabs defaultValue="overview">
          <RecordSheetTabsList>
            <TabsTrigger value="overview">Overview</TabsTrigger>
            <TabsTrigger value="similar">Similar</TabsTrigger>
          </RecordSheetTabsList>
          <TabsContent value="overview">
            <RecordSheetBody
              aside={
                <>
                  <RecordSheetSection title="Details">
                    <DescriptionList>
                      <DescriptionItem label="Reference ID" block mono>
                        <span className="inline-flex items-center gap-1">
                          fff187d3-be80-48bb-8bfc-1bdd1595a640
                          <CopyButton value="fff187d3-be80-48bb-8bfc-1bdd1595a640" />
                        </span>
                      </DescriptionItem>
                      <DescriptionItem label="Historic event" block>
                        No
                      </DescriptionItem>
                    </DescriptionList>
                  </RecordSheetSection>
                  <RecordSheetSection title="Entities">
                    <div className="flex items-center gap-2 text-sm">
                      <GlobeIcon className="size-4 text-text-secondary" />
                      <div>
                        <p>acme.com</p>
                        <p className="font-mono text-xs text-text-muted">
                          INT-4704677299365174950
                        </p>
                      </div>
                    </div>
                  </RecordSheetSection>
                  <RecordSheetSection title="History">
                    <ListEmptyState size="sm" title="No changes recorded" />
                  </RecordSheetSection>
                  <RecordSheetSection title="Comments">
                    <ListEmptyState size="sm" title="No comments yet" />
                    <Textarea
                      aria-label="Comment"
                      placeholder="Write a comment"
                    />
                    <Button variant="outline" size="sm" className="self-end">
                      Send
                    </Button>
                  </RecordSheetSection>
                </>
              }
            >
              <RecordSheetSection title="Credential">
                <DescriptionList>
                  <DescriptionItem label="Source file name">
                    urllogin pass[ulp]@cloud9base 09.10.(3).txt
                  </DescriptionItem>
                  <DescriptionItem label="URL">
                    <ExternalLink
                      href="https://portswigger.net"
                      underline="hover"
                    >
                      portswigger.net
                    </ExternalLink>
                  </DescriptionItem>
                  <DescriptionItem label="Email">
                    marcio.cruz@acme.com
                  </DescriptionItem>
                  <DescriptionItem label="Severity">
                    <span className="inline-flex items-center gap-2">
                      100 <SeverityBadge level="critical" />
                    </span>
                  </DescriptionItem>
                  <DescriptionItem label="Date added">
                    10/14/2026 21:38:16
                  </DescriptionItem>
                  <DescriptionItem label="Domain">
                    portswigger.net
                  </DescriptionItem>
                  <DescriptionItem label="Email domain">
                    acme.com
                  </DescriptionItem>
                  <DescriptionItem label="Date indexed">
                    10/14/2026 21:36:24
                  </DescriptionItem>
                  <DescriptionItem label="Password">••••••••</DescriptionItem>
                </DescriptionList>
              </RecordSheetSection>
            </RecordSheetBody>
          </TabsContent>
          <TabsContent value="similar">
            <RecordSheetBody>
              <ListEmptyState size="sm" title="No similar events" />
            </RecordSheetBody>
          </TabsContent>
        </RecordSheetTabs>
      </RecordSheetContent>
    </Sheet>
  )
}

Usage

import {
  RecordSheetBody,
  RecordSheetContent,
  RecordSheetHeader,
  RecordSheetSection,
  RecordSheetTabs,
  RecordSheetTabsList,
  RecordSheetTriage,
} from "@zenoxai/ui/patterns/record-sheet"

<Sheet open={Boolean(id)} onOpenChange={(open) => !open && close()}>
  <RecordSheetContent>
    <RecordSheetHeader title={useCaseName} severity="high" meta={badges} />
    <RecordSheetTriage>{statusField}{assigneeField}</RecordSheetTriage>
    <RecordSheetTabs defaultValue="overview">
      <RecordSheetTabsList>...</RecordSheetTabsList>
      <TabsContent value="overview">
        <RecordSheetBody aside={details}>{datasets}</RecordSheetBody>
      </TabsContent>
    </RecordSheetTabs>
  </RecordSheetContent>
</Sheet>

A record sheet is the record’s file, in four bands:

  1. Header: who the record is (an optional breadcrumb, icon, title, subtitle, badges and dates) and the sheet’s controls, always in one order: the position, previous, next, Copy link, the record’s own actions, Close. severity draws a 3px rule along the top in the record’s severity or priority color.
  2. Triage, only for a record with a workflow: the controls the tenant decides with (Status, Assignee, an asset’s State), each a Field with its label above, and the workflow action on the right (“Request takedown”). A decision bar takes this band too.
  3. Tabs: the record’s views, “Overview” first. A record with one view has no tabs.
  4. Body: the collected content in the main column, and the record’s context in aside, in this order: Details, Entities, History, Comments. With aside the sheet is xl and the columns split 7/5; without it, size="lg" and one column.

The rest of the standard:

  • Sections are Record Sheet Sections, flat with a compact heading; no card inside the sheet (zx/card-nesting).
  • Rows are a Description List: label beside the value in the main column, block (label above) in the side column.
  • A value already in the header or the triage band is not repeated in Details.
  • Empty values are Empty Value; optional dataset fields with no value are left out. An empty section is a List Empty State with size="sm".
  • Copy is Copy Button; links that leave the platform are External Link.
  • History is always named “History”; the comment button is “Send”.

Examples

An asset, with its actions

The record’s own actions (“Rescan”, a More menu) sit in the header between the navigation and Close. The asset’s State is the triage band; its other properties are Details in the side column.

function RecordSheetAsset() {
  const states = [
    { value: "approved", label: "Approved" },
    { value: "dependency", label: "Dependency" },
    { value: "dismissed", label: "Dismissed" },
  ]

  return (
    <Sheet>
      <SheetTrigger render={<Button variant="outline" />}>
        Open the asset
      </SheetTrigger>
      <RecordSheetContent>
        <RecordSheetHeader
          title="www.acme.com"
          mono
          copyValue="www.acme.com"
          meta={
            <>
              <Badge variant="outline">Host</Badge>
              <StatusBadge tone="success" icon={CircleIcon} label="Approved" />
            </>
          }
          position="3 of 28"
          onPrevious={() => {}}
          onNext={() => {}}
          actions={
            <>
              <Button variant="outline" size="sm">
                <RadarIcon data-icon="inline-start" />
                Rescan
              </Button>
              <DropdownMenu>
                <DropdownMenuTrigger
                  render={
                    <Button
                      variant="ghost"
                      size="icon-sm"
                      aria-label="More actions"
                    />
                  }
                >
                  <MoreHorizontalIcon />
                </DropdownMenuTrigger>
                <DropdownMenuContent align="end">
                  <DropdownMenuItem variant="destructive">
                    Remove asset
                  </DropdownMenuItem>
                </DropdownMenuContent>
              </DropdownMenu>
            </>
          }
        />
        <RecordSheetTriage>
          <Field>
            <FieldLabel htmlFor="asset-state">State</FieldLabel>
            <Select items={states} defaultValue="approved">
              <SelectTrigger id="asset-state" className="w-full">
                <SelectValue />
              </SelectTrigger>
              <SelectContent>
                {states.map((state) => (
                  <SelectItem key={state.value} value={state.value}>
                    {state.label}
                  </SelectItem>
                ))}
              </SelectContent>
            </Select>
          </Field>
        </RecordSheetTriage>
        <RecordSheetTabs defaultValue="overview">
          <RecordSheetTabsList>
            <TabsTrigger value="overview">Overview</TabsTrigger>
            <TabsTrigger value="chain">Discovery chain</TabsTrigger>
            <TabsTrigger value="history">History</TabsTrigger>
          </RecordSheetTabsList>
          <TabsContent value="overview">
            <RecordSheetBody
              aside={
                <RecordSheetSection title="Details">
                  <DescriptionList>
                    <DescriptionItem label="UUID" block mono>
                      999def3a-d2ae-4e6d-9f7f-e2f81bc27416
                    </DescriptionItem>
                    <DescriptionItem label="State set by" block>
                      System
                    </DescriptionItem>
                    <DescriptionItem label="External ID" block>
                      <EmptyValue />
                    </DescriptionItem>
                    <DescriptionItem label="Confidence" block>
                      81%
                    </DescriptionItem>
                    <DescriptionItem label="Last scan" block>
                      10/25/2026 10:11:47
                    </DescriptionItem>
                    <DescriptionItem label="IPs" block mono>
                      104.248.111.57
                    </DescriptionItem>
                  </DescriptionList>
                </RecordSheetSection>
              }
            >
              <RecordSheetSection title="HTTP">
                <DescriptionList>
                  <DescriptionItem label="content_type">
                    text/html; charset=UTF-8
                  </DescriptionItem>
                  <DescriptionItem label="server">
                    Apache/2.4.52 (Ubuntu)
                  </DescriptionItem>
                  <DescriptionItem label="status_code">302</DescriptionItem>
                </DescriptionList>
              </RecordSheetSection>
              <RecordSheetSection title="TLS">
                <DescriptionList>
                  <DescriptionItem label="cipher_suite" mono>
                    TLS_AES_128_GCM_SHA256
                  </DescriptionItem>
                  <DescriptionItem label="version">TLS 1.3</DescriptionItem>
                </DescriptionList>
              </RecordSheetSection>
            </RecordSheetBody>
          </TabsContent>
          <TabsContent value="chain">
            <RecordSheetBody>
              <ListEmptyState size="sm" title="No hops recorded" />
            </RecordSheetBody>
          </TabsContent>
          <TabsContent value="history">
            <RecordSheetBody>
              <ListEmptyState size="sm" title="No changes recorded" />
            </RecordSheetBody>
          </TabsContent>
        </RecordSheetTabs>
      </RecordSheetContent>
    </Sheet>
  )
}

A catalog record, one column

A record without a workflow or context, such as a CVE in the catalog, has no triage band and no side column: size="lg".

<Sheet>
  <SheetTrigger render={<Button variant="outline" />}>
    Open the CVE
  </SheetTrigger>
  <RecordSheetContent size="lg">
    <RecordSheetHeader
      title="CVE-2025-39946"
      mono
      copyValue="CVE-2025-39946"
      subtitle="tls: make sure to abort the stream if headers are bogus"
      meta={<SeverityBadge level="medium" />}
    />
    <RecordSheetTabs defaultValue="overview">
      <RecordSheetTabsList>
        <TabsTrigger value="overview">Overview</TabsTrigger>
        <TabsTrigger value="products">Affected products</TabsTrigger>
      </RecordSheetTabsList>
      <TabsContent value="overview">
        <RecordSheetBody>
          <RecordSheetSection title="Description">
            <p className="text-sm text-text-secondary">
              In the Linux kernel, the following vulnerability has been
              resolved: tls: make sure to abort the stream if headers are
              bogus.
            </p>
          </RecordSheetSection>
          <RecordSheetSection title="CVE details">
            <DescriptionList>
              <DescriptionItem label="CVSS">5.5</DescriptionItem>
              <DescriptionItem label="EPSS">10.3%</DescriptionItem>
              <DescriptionItem label="Exploited">No</DescriptionItem>
              <DescriptionItem label="Published">
                10/04/2025 04:31:07
              </DescriptionItem>
              <DescriptionItem label="Weaknesses (CWE)">
                <EmptyValue />
              </DescriptionItem>
            </DescriptionList>
            <ExternalLink
              href="https://nvd.nist.gov/vuln/detail/CVE-2025-39946"
              confirm={false}
              underline="hover"
              className="self-start text-sm"
            >
              View on NVD
            </ExternalLink>
          </RecordSheetSection>
        </RecordSheetBody>
      </TabsContent>
      <TabsContent value="products">
        <RecordSheetBody>
          <ListEmptyState size="sm" title="No affected products" />
        </RecordSheetBody>
      </TabsContent>
    </RecordSheetTabs>
  </RecordSheetContent>
</Sheet>

Accessibility

  • The title names the sheet. Previous, Next, Copy link and Close are named buttons with tooltips; previous or next is disabled when there is no record on that side.
  • Each section is a region named by its h3 heading.
  • Pass translated texts through labels.

API reference

Generated from the TypeScript source. className, style and children are accepted everywhere and omitted below.

RecordSheetBody

RecordSheetBody is the scrolling body of a record sheet: the collected content in the main column and, when given, the record's context in a side column at 7/5. Narrower than 56rem, the side column follows the main one.

PropTypeDefault
asideReactNode

The record's context in the side column, in this order: Detalhes, Entidades, Histórico, Comentários. Without it the body is one column.

-

RecordSheetContent

RecordSheetContent is the panel of a record sheet: a right sheet that holds the record's bands (header, triage, tabs, body) with no gap between them. The header renders its own close button.

PropTypeDefault
finalFocusboolean | RefObject<HTMLElement | null> | ((closeType: InteractionType) => boolean | void | HTMLElement | null)

Determines the element to focus when the dialog is closed.

-
initialFocusboolean | RefObject<HTMLElement | null> | ((openType: InteractionType) => boolean | void | HTMLElement | null)

Determines the element to focus when the dialog is opened. By default, focus moves to the first tabbable element inside the popup, except when the dialog is opened by touch — then the popup itself is focused to avoid opening the virtual keyboard.

-
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, DialogPopupState>

Allows you to replace the component's HTML element with a different tag, or compose it with another component.

-
showOverlayboolean

Renders the dimmed backdrop. Turning it off does not make the sheet non-modal; set `modal={false}` on Sheet for that.

-
size"lg" | "xl"

`xl` (1100px) for a record with a side column, `lg` (800px) for one column.

xl

RecordSheetHeader

RecordSheetHeader is the first band of a record sheet: who the record is (an optional breadcrumb, icon, title, subtitle, badges and dates) on the left, and the sheet's controls on the right, always in one order: the position, previous, next, Copy link, the record's actions, Close.

PropTypeDefault
actionsReactNode

The record's own actions, between the navigation and Close: "Ver no catálogo", "Varrer agora", "Revelar", a More menu.

-
breadcrumbReactNode

A Breadcrumb above the title, when the sheet moved from another record.

-
copyValuestring

The value Copy writes; renders a Copy button beside the title.

-
iconReactNode

A glyph before the title, such as the dataset or source icon.

-
labelsPartial<RecordSheetLabels>

Already-translated texts. English by default.

-
linkstring

The record's link; renders a Copy link button.

-
metaReactNode

The badges, then the dates with their icon.

-
monoboolean

Sets the title in the monospace face, for identifiers.

false
onNext(() => void)

Opens the next record; omit when there is none.

-
onPrevious(() => void)

Opens the previous record; omit when there is none.

-
positionstring

The position in the list, already formatted: "1 de 4".

-
severity"info" | "high" | "low" | "critical" | "medium"

A 3px rule along the top, in the record's severity or priority color.

-
subtitleReactNode

One line under the title, such as a CVE's catalog title.

-
title*ReactNode

The record, as each module names it: a use case, a CVE id, an asset.

-

RecordSheetSection

RecordSheetSection is one titled block of a record sheet's body: a dataset, a description, Detalhes, Histórico. Flat, with a compact heading and no card around it: the sheet is already the container.

PropTypeDefault
actionReactNode

One action on the right of the title.

-
title*ReactNode-

RecordSheetTabs

RecordSheetTabs holds the record's views. The panels fill the rest of the sheet, so each one scrolls on its own under the fixed bands. "Visão Geral" is always the first tab.

PropTypeDefault
defaultValueany

The default value. Use when the component is not controlled. When the value is `null`, no Tab will be active.

0
onValueChange((value: any, eventDetails: TabsRootChangeEventDetails) => void)

Callback invoked when new value is being set.

-
orientation"horizontal" | "vertical"

The component orientation (layout flow direction).

horizontal
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, TabsRootState>

Allows you to replace the component's HTML element with a different tag, or compose it with another component.

-
valueany

The value of the currently active `Tab`. Use when the component is controlled. When the value is `null`, no Tab will be active.

-

RecordSheetTabsList

RecordSheetTabsList is the tab strip, a line under the bands above it.

PropTypeDefault
activateOnFocusboolean

Whether to automatically change the active tab on arrow key focus. Otherwise, tabs will be activated using <kbd>Enter</kbd> or <kbd>Space</kbd> key press.

false
loopFocusboolean

Whether to loop keyboard focus back to the first item when the end of the list is reached while using the arrow keys.

true
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, TabsListState>

Allows you to replace the component's HTML element with a different tag, or compose it with another component.

-
variant"line" | "default" | null

`default` is a bordered track whose active tab takes the selection colors, the same control as a segmented ToggleGroup; `line` is a full-width bar on a bottom rule, with the active tab underlined, for page headers, sheets and status pivots.

-

RecordSheetTriage

RecordSheetTriage is the band under the header for a record with a workflow: the controls the tenant decides with (Status, Atribuído, an asset's Estado) on the left, each a Field with its label above, and the workflow action on the right. EASM's decision bar takes this band too.

PropTypeDefault
actionReactNode

The workflow action on the right, such as "Solicitar Takedown".

-