Record Sheet
betaThe sheet that opens one record from a list, in four bands every module reads the same way.
@zenoxai/ui/patterns/record-sheet Markdownfunction 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:
- 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.
severitydraws a 3px rule along the top in the record’s severity or priority color. - 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.
- Tabs: the record’s views, “Overview” first. A record with one view has no tabs.
- Body: the collected content in the main column, and the record’s context in
aside, in this order: Details, Entities, History, Comments. Withasidethe sheet isxland 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
h3heading. - 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.
| Prop | Type | Default |
|---|---|---|
aside | ReactNodeThe 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.
| Prop | Type | Default |
|---|---|---|
finalFocus | boolean | RefObject<HTMLElement | null> | ((closeType: InteractionType) => boolean | void | HTMLElement | null)Determines the element to focus when the dialog is closed. | - |
initialFocus | boolean | 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. | - |
render | ReactElement<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. | - |
showOverlay | booleanRenders 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.
| Prop | Type | Default |
|---|---|---|
actions | ReactNodeThe record's own actions, between the navigation and Close: "Ver no catálogo", "Varrer agora", "Revelar", a More menu. | - |
breadcrumb | ReactNodeA Breadcrumb above the title, when the sheet moved from another record. | - |
copyValue | stringThe value Copy writes; renders a Copy button beside the title. | - |
icon | ReactNodeA glyph before the title, such as the dataset or source icon. | - |
labels | Partial<RecordSheetLabels>Already-translated texts. English by default. | - |
link | stringThe record's link; renders a Copy link button. | - |
meta | ReactNodeThe badges, then the dates with their icon. | - |
mono | booleanSets 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. | - |
position | stringThe 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. | - |
subtitle | ReactNodeOne line under the title, such as a CVE's catalog title. | - |
title* | ReactNodeThe 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.
| Prop | Type | Default |
|---|---|---|
action | ReactNodeOne 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.
| Prop | Type | Default |
|---|---|---|
defaultValue | anyThe 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 |
render | ReactElement<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. | - |
value | anyThe 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.
| Prop | Type | Default |
|---|---|---|
activateOnFocus | booleanWhether 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 |
loopFocus | booleanWhether to loop keyboard focus back to the first item when the end of the list is reached while using the arrow keys. | true |
render | ReactElement<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.
| Prop | Type | Default |
|---|---|---|
action | ReactNodeThe workflow action on the right, such as "Solicitar Takedown". | - |