Skip to content
Component installs need the registry setup
VegaStack Design

Record Peek

A record opened beside its list — non-modal and resizable, ↑/↓ and J/K to walk, ⌘↵ for the full page, full-screen on a phone.

Status
preview
Since
0.25.88
Accessibility pattern
named panel, keyboard walk, focus returns to the row

Last updated

Install

Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for Record Peek before installation. Retain the digest printed by this command.

pnpm exec vegastack-design verify record-peek

Install with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.

pnpm dlx shadcn@4.21.0 add @vegastack/record-peek

Run the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.

The same command installs the registry items it composes: @vegastack/button, @vegastack/direction, @vegastack/drawer, @vegastack/kbd, @vegastack/tooltip, @vegastack/use-platform.

Usage

import { RecordPeek, RecordPeekBody } from "@/components/ui/record-peek";
import { RecordHeader } from "@/components/ui/record-header";

<RecordPeek
  open={peekId != null}
  onOpenChange={(open) => !open && closePeek()}
  label="Task"
  position={{ index, total: order.length }}
  onPrevious={index > 0 ? () => openPeek(order[index - 1]) : undefined}
  onNext={
    index < order.length - 1 ? () => openPeek(order[index + 1]) : undefined
  }
  fullLink={<Link href={`/tasks/${peekId}`} />}
  actions={<TaskActionsMenu id={peekId} />}
  storageKey="peek-width"
  // The row it was opened from — kept when the peek opens, since `peekId` is gone by close time.
  returnFocus={() => originRow.current}
>
  <RecordPeekBody>
    <RecordHeader level={2} keyBadge={task.ref} title={task.title} />
    <TaskSections task={task} />
  </RecordPeekBody>
</RecordPeek>;

Scope

The one way a record opens from a list: tasks, table rows, meetings, customers — any record the app registers. The peek knows no record kinds; the app passes the noun (label), where the record sits in its list (position), how to walk it (onPrevious, onNext), its full page (fullLink, fullHref or onOpenFull), its actions and its body. Documents and files keep opening as a full page or the file viewer.

Anatomy

RecordPeek — data-slot="record-peek" | "record-peek-actions" | "record-peek-close" | "record-peek-header" | "record-peek-next" | "record-peek-position" | "record-peek-previous" | "record-peek-resize"
RecordPeekBody — data-slot="record-peek-body"

Examples

A list and its peek

Click a row to open it. While the peek is open, ↑/↓ and J/K walk the list (never while typing, while a menu, popover or another dialog is open, and ↑/↓ never from a control that uses them — a grid, a tab list, a select), "n of N" follows, the buttons are disabled at the ends, ⌘↵ / Ctrl+↵ opens the full page and Esc closes the peek and puts focus back on the row it came from. The list stays usable: a click on another row switches the record. The open row carries data-peek-active — DataList, DataGrid and Board set it from activeRowId; any other list uses useRecordPeekRow, which also scrolls the row into view as the peek walks.

Width and phones

The separator on the start edge resizes the panel from 480px up to 90% of the window (minWidth, maxShare), by pointer or with ← → (32px), Home and End; storageKey remembers the width in this browser. Below the tablet tier (40rem) the peek is the whole screen, edge to edge, padded by the safe areas, and the separator goes.

<RecordPeek storageKey="regent:peek-width" minWidth={480} maxShare={0.9} … />

API Reference

PropTypeDefaultDescription
label*string—The record's kind as a noun ("Task", "Row") — the panel's accessible name, and the noun in the previous/next labels.
onOpenChange*(open: boolean) => void—Called when the peek asks to close (Esc, the close button) — and with true never.
open*boolean—Whether the peek is open.
actionsReact.ReactNode—The header's end slot before open-full and close: the record's ⋯ menu, a Share button.
childrenReact.ReactNode—The record body: RecordPeekBody holding a RecordHeader and the record's sections.
closeLabelstring"Close"
defaultWidthnumber640The starting width when uncontrolled, in px.
formatPosition((index: number, total: number) => string)(index, total) => `${index + 1} of ${total}`"n of N" text for the position, for another language.
fullHrefstring—The record's full page. Rendered as a link (pass fullLink for a router link); ⌘↵ / Ctrl+↵ follows it.
fullLabelstring"Open full page"
fullLinkReact.ReactElement<unknown, string | React.JSXElementConstructor<any>>—A router link to render the open-full button as (<Link href="/tasks/42" />); its href wins over fullHref for the button, and ⌘↵ clicks it.
keyboardbooleantrueBind ↑/↓, J/K and ⌘↵ / Ctrl+↵ on the document while open — never while typing, while a menu, list box, popover or another dialog is open, and ↑/↓ never from a control that uses them (a grid, a tab list, a select trigger).
maxSharenumber0.9The widest the separator goes, as a share of the window.
minWidthnumber480The narrowest the separator goes, in px.
onNext(() => void)—Walk to the next record (button, ↓, J). Omit when there is none.
onOpenFull(() => void)—Opens the full page instead of a link (a client navigation the app owns).
onPrevious(() => void)—Walk to the previous record (button, ↑, K). Omit when there is none.
onWidthChange((width: number) => void)—Called as the separator moves the width (pointer, or ← → / Home / End on it).
positionRecordPeekPosition—The open record's place in its list; shows "3 of 41" and disables previous/next at the ends.
returnFocusHTMLElement | (() => HTMLElement | null) | null—Where focus goes when the peek closes: the row it was opened from. A function is read at close time — keep the row (or its id) from when the peek opened, since the open record's id is usually cleared by then.
storageKeystring—Remembers an uncontrolled width in localStorage under this key, per browser.
widthnumber—The panel width in px (controlled). Pair with onWidthChange.

Data attributes and CSS variables on RecordPeek

AttributeValues
data-slot"record-peek" | "record-peek-actions" | "record-peek-close" | "record-peek-header" | "record-peek-next" | "record-peek-position" | "record-peek-previous" | "record-peek-resize"
--record-peek-maxCSS custom property
--record-peek-widthCSS custom property

RecordPeekBody

RecordPeekBody takes a div's props: the scrolling body below the header, padded like a record page's main column.

useRecordPeekRow

PropTypeDefaultDescription
data-peek-active*"" | undefined—"" on the open row, absent otherwise — the attribute every list styles the same way.
ref*(instance: T | null) => void | (() => VoidOrUndefinedOnly) | React.DO_NOT_USE_OR_YOU_WILL_BE_FIRED_CALLBACK_REF_RETURN_VALUES[keyof React.DO_NOT_USE_OR_YOU_WILL_BE_FIRED_CALLBACK_REF_RETURN_VALUES]—Attach to the row, so it can scroll into view when it becomes the open one.

Accessibility

KeyDoes
↓ / JNext record (while open; not in a field, menu or popover)
↑ / KPrevious record
⌘↵ / Ctrl+↵Open the full page
EscClose; focus returns to the row (returnFocus)
← / → on the separatorWider / narrower by 32px; Home and End go to the bounds
  • The panel is a non-modal dialog named by label; the page behind it stays in the tab order.
  • The buttons carry aria-keyshortcuts, and the open-full tooltip shows the platform's chord.
  • The separator is a focusable role="separator" with its value, minimum and maximum.
ContractStates tested
Behaviourclosed, open, first, last, resizing, phone
Accessibilitylabeled
Visualdefault, with-icon

Do / Don't

Do
Open every record from a list in RecordPeek, with the list's own order for ↑/↓ and the row marked with data-peek-active.
Don't
Build a second side panel or a modal sheet for one record kind, or scrape the list order from the DOM.

On this page