Gesso
Data display

Record list

Records grouped into collapsible buckets, one line each, with the keyboard moving between them.

Record list shows records as one line each, grouped into buckets such as a status, with a count and a collapse control on every group. A row is an Item at its dense size and a group is a Collapsible, so it reads as a list and not a table. The list owns the keyboard: j and k or the arrow keys move between rows, and Enter opens the selected one. A menu of Switches shows or hides the slots on the right of each row, and it does not add or remove columns.

The Issues example composes it into a full project surface.

JKmove↵open

GES-191
Record list keyboard
a11y
Gesso
JPSep 8
GES-184
Command palette
kit
Gesso
MCSep 4
GES-198
Sidebar icon collapse
chrome
Gesso
AOAug 28

No issues

Nothing in Canceled yet.

Usage

import {
  RecordGroup,
  RecordList,
  RecordRow,
} from '@/components/ui/record-list';

<RecordList onOpen={setOpenId}>
  <RecordGroup name="Todo" count={rows.length} onCreate={create}>
    {rows.map((row) => (
      <RecordRow key={row.id} id={row.id} title={row.title} />
    ))}
  </RecordGroup>
</RecordList>

The list holds no records and no schema. You map your own rows into RecordRow, and you keep what the id points at and what happens when a row opens.

Groups

RecordGroup is a heading row with a caret, an optional icon, the name, and a count, and pressing it collapses the rows under it. You pass count yourself, so it can show a total that differs from the rows you render. A group is open by default, and defaultOpen={false} starts it closed.

onCreate adds a ghost button after the heading, named Add in {name}. A group whose count is 0 shows empty instead of its children, or a default empty state that says "No issues" when you give none, so pass your own empty for any other kind of record.

A collapsed group unmounts its rows, so keyboard movement skips them until the group opens. The Canceled group in the demo above has a count of 0 and shows its empty state.

Rows

RecordRow takes an id, shown at the start in a small monospace column, and a title, which truncates. The id column is at least 56px wide and grows to keep a long id, like RAMSON-1, on one line. The title takes any node, so it can hold a name with a mark beside it. statusIcon draws before the title. Four slots on the right take any node, labels, project, assignee, and dueDate, and the display prop decides which of them render. trailing comes after them and always renders, for meta that the display menu does not govern. Hover draws bg-fill and the selected row draws bg-selected.

A click anywhere on the row opens it, the empty space among the right-hand slots included. A click on a control in statusIcon or in the right-hand slots stays with that control: a button, a link, an input, or an element with a menuitem, menuitemradio, menuitemcheckbox, or option role, including one in a menu the control opens. So a chip that is a menu or a link does not also open the row, and neither does picking a status from a menu in statusIcon. The title has no such stop, so a control you put in title has to stop its own click. dragHandle draws a grip at the start of the row. It is only an icon, and the row does not drag until you wire that.

Rows that only read

openable={false} keeps the row's layout and drops everything that makes it a record you open: no hover fill, no pointer cursor, no selection, no onOpen, and no stop for the arrow keys. It needs no RecordList, so a read-only list, such as projects shown to a viewer, can use the same rows without the list's keyboard model.

<div>
  {projects.map((project) => (
    <RecordRow
      key={project.id}
      id={project.key}
      title={project.name}
      trailing={<span className="text-xs text-fg-muted">{project.progress}%</span>}
      openable={false}
    />
  ))}
</div>

Choose what each row shows

RecordDisplayMenu is a button that opens a Menu with a Switch for each slot. It takes the current RecordDisplaySlots object as value and calls onChange with a new one, and the menu stays open while you toggle. Pass the same object to every row's display. DEFAULT_RECORD_DISPLAY shows all four.

import {
  DEFAULT_RECORD_DISPLAY,
  RecordDisplayMenu,
} from '@/components/ui/record-list';

const [display, setDisplay] = React.useState(DEFAULT_RECORD_DISPLAY);

<RecordDisplayMenu value={display} onChange={setDisplay} />
<RecordRow id={row.id} title={row.title} display={display} labels={tags} />

Selection and keyboard

One row at a time is selected. ArrowDown or j moves to the next, and ArrowUp or k to the previous, across groups and wrapping at either end. With no selection in the focused group, down picks its first row and up its last. Enter opens the selected row, and so does a click, which also selects it. Focusing something inside a row selects that row as well.

onOpen receives the row's id. To control the selection, pass selectedId and onSelectedIdChange, and leave both off for the list to hold it. The keys walk the rows in the order they are on screen, so a collapsed group is skipped and reordered rows are walked in their new order.

Props

RecordList takes the props of a div, and the rest take theirs, plus these.

PropTypeDefaultWhat it does
onOpen(id: string) => voidOn RecordList. Called with the row's id on Enter or a click.
selectedIdstring | nullOn RecordList. The selected row, for a controlled list.
onSelectedIdChange(id: string) => voidOn RecordList. Called when the selection moves.
namestringOn RecordGroup. The heading text.
countnumberOn RecordGroup. The number beside the name. At 0 the group shows its empty state instead of its children.
iconReactNodeOn RecordGroup. Drawn before the name.
defaultOpenbooleantrueOn RecordGroup. Whether the group starts expanded.
onCreate() => voidOn RecordGroup. Adds a ghost button named Add in {name}.
emptyReactNodeA "No issues" empty stateOn RecordGroup. Shown when count is 0.
idstringOn RecordRow. The row's identity, shown at its start and passed to onOpen.
titleReactNodeOn RecordRow. The row's text, or any node. It truncates.
statusIconReactNodeOn RecordRow. Drawn before the title.
labels project assignee dueDateReactNodeOn RecordRow. The right-hand slots.
displayRecordDisplaySlotsAll four onOn RecordRow. Which slots render.
trailingReactNodeOn RecordRow. Drawn after the four slots, whatever display says.
openablebooleantrueOn RecordRow. false keeps the look and drops hover, selection, open, and the arrow keys, and the row then works outside RecordList.
dragHandlebooleanfalseOn RecordRow. Draws a grip icon. It does not make the row draggable.

Accessibility

  • The list is a group named "Records", and each group's rows are a listbox named for the group, with the heading, the create button, and the empty state outside it. Pass your own aria-label to name the whole list.
  • Each group's listbox is one tab stop. Focus stays on it while the selection moves, through aria-activedescendant, and each row is an option with aria-selected. Moving past a group's last row moves focus to the next group's listbox. A row's nested controls are reachable by Tab, and a click on one in statusIcon or the right-hand slots stays with it and does not open the row.
  • A row with openable={false} has no role, no aria-selected, and no place in the arrow-key order. Put such rows in a plain container rather than a RecordGroup, whose listbox expects options.
  • Keys are ArrowDown, ArrowUp, j, k, and Enter, and they act only while a listbox itself has focus, so a key pressed in a field or a menu inside a row stays there. There is no Home or End, and no type to search.
  • A row's selected state is exposed through aria-selected and drawn in bg-selected, and the focus ring is 2px on the group's listbox, not on the row.
  • Group headings are native buttons, so Enter and Space collapse them, and the caret turn stops under reduced motion.
  • Target size follows the row, which is as tall as a dense Item, and there is no 44px minimum. The create button is a small ghost button with an icon and no text, named Add in {name}.
  • Each display property is a menuitemcheckbox named by its label, with aria-checked, and Enter or Space toggles it while the menu stays open. The Switch beside it only shows the state and is hidden from assistive tech.

When to use something else

  • Table for rows that share columns the reader compares and sorts. Record list has no columns, and its right-hand slots are a fixed set.
  • Cell fields when values are edited in the row. A record here opens elsewhere, in a panel or a page.
  • Description list for the facts about one record, as labels and values.
  • A plain list of Item rows for a short list that needs no groups, selection, or keyboard model.
  • A data grid library, or a virtualizer such as TanStack Virtual, when the list has thousands of rows. Record list renders every row, and it exports useRecordListVirtualizer only as an empty stub.

Install

shadcn add @gesso/record-list

On this page