Skip to content
Component installs need the registry setup
VegaStack Design

Record List

A numbered list of the records a change affects, with a Show more footer, group headings and a "what differs" table for conflicts.

Status
stable
Since
0.23.54
Accessibility pattern
ordered list, named new-tab links

Last updated

  1. Orbit Track 30W
    Missing beam angle
  2. Halo Downlight 12WDraft
  3. Linea Profile 2m
  4. Nova Pendant
    Missing beam angle

Install

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

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

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

Usage

import {
  RecordList,
  RecordListItem,
  RecordListMore,
} from "@/components/ui/record-list";

<RecordList aria-label="Affected products">
  {rows.map((row) => (
    <RecordListItem
      key={row.id}
      icon={<Package />}
      title={row.name}
      badge={<StatusBadge status={row.status} />}
      description={row.newIssue}
      href={productHref(row.id)}
    />
  ))}
</RecordList>
<RecordListMore remaining={total - rows.length} onShowMore={loadMore} />;

Scope

BehaviourWhere it lives
The rows, the total, the pagingHost — RecordListMore is a controlled footer
Selectable or actionable rowsItem or DataList
The dialog around itDialog — the list sits in its DialogBody scroll body
Tabs with counts over the listTabs + RecordTabCount — see Review list dialog
The error around a conflictAlert — see Conflict message

Anatomy

RecordList — data-slot="record-list"
RecordListItem — data-slot="record-list-description" | "record-list-icon" | "record-list-item" | "record-list-link" | "record-list-title"
RecordListMore — data-slot="record-list-more"
RecordListGroup — data-slot="record-list-group" | "record-list-group-title"
RecordDiff — data-slot="record-diff" | "record-diff-row"

Examples

Affected records

Each row is numbered, then a muted record-type icon, the name, a small ↗ link right after the name, an optional badge and an optional description line. The ↗ opens the record in a new tab — named Open {name} in a new tab — and shows on row hover or focus within the row, and always on a touch screen.

  1. Orbit Track 30W
    Missing beam angle
  2. Halo Downlight 12WDraft
  3. Linea Profile 2m
  4. Nova Pendant
    Missing beam angle

Show more

RecordListMore says how many records are not shown and offers "Show more". The host appends the next batch to the same RecordList, so the numbers continue — 11., 12., … — instead of starting again.

  1. Orbit Track 30W
  2. Halo Downlight 12W
  3. Linea Profile 2m
  4. Nova Pendant
  5. Arc Wall Washer
  6. Orbit Track 30W · 2
  7. Halo Downlight 12W · 2
  8. Linea Profile 2m · 2
  9. Nova Pendant · 2
  10. Arc Wall Washer · 2
and 13 more

Review list dialog

The pattern for "these records will change — check them first": a Dialog (size="lg") with a title and one plain line, pill Tabs whose triggers each carry a count (RecordTabCount), a numbered RecordList per tab inside DialogBody — the one region that scrolls, so the title, tabs and footer hold still — and a footer of Cancel plus the primary or destructive action. The second line (description) says why a record is in its tab ("Edited by Ravi", "3 products"). Give the Tabs root min-h-0 so the body can scroll inside the dialog.

<DialogContent size="lg">
  <DialogHeader>
    <DialogTitle>Undo this run?</DialogTitle>
    <DialogDescription>
      758 draft products will be removed. 6 stay because someone changed them.
    </DialogDescription>
  </DialogHeader>
  <Tabs defaultValue="remove" className="min-h-0">
    <TabsList>
      <TabsTrigger value="remove">
        Will be removed <RecordTabCount count={758} />
      </TabsTrigger>
      <TabsTrigger value="stay">
        Will stay <RecordTabCount count={6} />
      </TabsTrigger>
    </TabsList>
    <DialogBody>
      <TabsContent value="remove">
        <RecordList aria-label="Products that will be removed">…</RecordList>
      </TabsContent>
      <TabsContent value="stay">
        <RecordList aria-label="Products that will stay">
          <RecordListItem
            icon={<Package />}
            title={name}
            description="Edited by Ravi"
            href={href}
          />
        </RecordList>
      </TabsContent>
    </DialogBody>
  </Tabs>
  <DialogFooter>
    <DialogClose render={<Button variant="secondary" />}>Cancel</DialogClose>
    <Button variant="destructive">Remove 758 products</Button>
  </DialogFooter>
</DialogContent>

Conflict message

When a save is refused because a record clashes with one that exists, say so plainly: a destructive Alert with a title ("This product already exists"), one line saying what to do, the clashing record in a numbered RecordList with its ↗ link, and — optionally — RecordDiff, the compact "what differs" table (spec | this product | existing product). RecordDiff marks the rows whose values differ (a tinted row in full ink, and "differs" for screen readers) and keeps matching rows muted, so it is clear why the two count as the same. The same composition works inline under a field. Give AlertDescription min-w-0 so long names truncate and the table scrolls sideways instead of widening the alert on a phone.

This product already exists

Every choice matches a product in this family. Change at least one choice to save it.

  1. Alpha 86mm · 10W · 3000K · Black
    Active · created by the generator
SpecThis productExisting product
Power10W10W
Colour temperature3000K3000K
TrimBlackBlack
Beam angle24°24°
Lumen output, differs980 lm1,020 lm
Cable length, differs—2 m
<Alert variant="destructive">
  <CircleAlert />
  <AlertTitle>This product already exists</AlertTitle>
  <AlertDescription className="flex min-w-0 flex-col gap-3">
    <p>
      Every choice matches a product in this family. Change at least one choice
      to save it.
    </p>
    <RecordList aria-label="Matching product">
      <RecordListItem
        icon={<Package />}
        title={existing.name}
        href={existing.url}
      />
    </RecordList>
    <RecordDiff
      columns={["This product", "Existing product"]}
      rows={specs.map((s) => ({ label: s.name, values: [s.mine, s.theirs] }))}
    />
  </AlertDescription>
</Alert>

Grouped conflicts

When one change would make several sets of records identical — a settings save that removes the spec that told them apart — list each set under RecordListGroup, a short muted heading ("Group 1 · 3 products") that also names its list for assistive technology.

Can't save these settings

Without Beam angle, the products in each group below would be identical. Delete or change them first.

Group 1 · 3 products

  1. Alpha 86mm · 10W · 3000K · Black · 24°
  2. Alpha 86mm · 10W · 3000K · Black · 36°
  3. Alpha 86mm · 10W · 3000K · Black · 60°

Group 2 · 2 products

  1. Alpha 86mm · 15W · 4000K · White · 24°
  2. Alpha 86mm · 15W · 4000K · White · 36°

API Reference

PropTypeDefaultDescription
aria-labelstring—Accessible name of the list ("Affected products").

Data attributes and CSS variables on RecordList

AttributeValues
data-slot"record-list"

RecordListItem

PropTypeDefaultDescription
title*React.ReactNode—The record's name.
badgeReact.ReactNode—A status after the name and its link — a Badge.
descriptionReact.ReactNode—One line under the name — what happens to this record.
hrefstring—The record's page. Adds a ↗ link after the name that opens it in a new tab.
iconReact.ReactNode—The record type's icon, drawn muted before the name.
openLabelstring`Open ${title} in a new tab` when `title` is a string, else "Open in a new tab"Accessible name of the ↗ link.

Data attributes and CSS variables on RecordListItem

AttributeValues
data-slot"record-list-description" | "record-list-icon" | "record-list-item" | "record-list-link" | "record-list-title"

RecordListMore

PropTypeDefaultDescription
remaining*number—How many records are not shown yet. Nothing renders at zero.
labelstring"Show more"The button's label.
loadingbooleanfalseA batch is loading: the button shows its spinner.
onShowMore(() => void)—Load the next batch. Without it the footer only says how many more there are.

Data attributes and CSS variables on RecordListMore

AttributeValues
data-slot"record-list-more"

RecordListGroup

PropTypeDefaultDescription
title*React.ReactNode—The group's heading ("Group 1 · 3 products"), which also names it for assistive technology.

Data attributes and CSS variables on RecordListGroup

AttributeValues
data-slot"record-list-group" | "record-list-group-title"

RecordDiff

PropTypeDefaultDescription
columns*readonly React.ReactNode[]—The record columns' headings ("This product", "Existing product").
rows*readonly RecordDiffRow[]—The specs to compare, one row each.
differsLabelstring"differs"Read after a differing row's label by screen readers.
labelHeadingReact.ReactNode"Spec"The first column's heading.

Data attributes and CSS variables on RecordDiff

AttributeValues
data-differs""
data-slot"record-diff" | "record-diff-row"
PropTypeDefaultDescription
label*React.ReactNode—The spec's name ("Beam angle").
values*readonly React.ReactNode[]—One value per column, in column order. An empty value reads "—".
differsbooleanthe values compared as textWhether the values differ. By default, rows whose values are all strings or numbers are compared as text; pass it for anything else.
keystring—Stable key; defaults to the label when it is a string.

Accessibility

  • The list is an <ol>, so a screen reader announces each record's position; name it with aria-label.
  • The record-type icon is decorative (aria-hidden); the name carries the meaning.
  • The ↗ link is a real link with its own name, Open {name} in a new tab, and is reachable by Tab even while it is visually hidden — focusing it shows it.
  • RecordListGroup is a role="group" named by its heading. RecordDiff is a real table with column headers; a differing row reads its label followed by "differs", so the mark is not colour alone.
KeyAction
TabMove to the next record's ↗ link.
EnterOpen the record in a new tab.
ContractStates tested
Behaviourdefault, link, description, badge, more, no-more
Accessibilitylabeled, semantic-html, browser-accessibility-test
Visualdefault, hover, coarse-pointer

Do / Don't

Do
List the records a change will touch, in a confirmation dialog, with a link to check each one without losing the dialog.
Don't
Use it as a navigable list or a picker — rows here are context, not controls; use Item or DataList.

On this page