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
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.
| Prop | Type | Default | What it does |
|---|---|---|---|
onOpen | (id: string) => void | On RecordList. Called with the row's id on Enter or a click. | |
selectedId | string | null | On RecordList. The selected row, for a controlled list. | |
onSelectedIdChange | (id: string) => void | On RecordList. Called when the selection moves. | |
name | string | On RecordGroup. The heading text. | |
count | number | On RecordGroup. The number beside the name. At 0 the group shows its empty state instead of its children. | |
icon | ReactNode | On RecordGroup. Drawn before the name. | |
defaultOpen | boolean | true | On RecordGroup. Whether the group starts expanded. |
onCreate | () => void | On RecordGroup. Adds a ghost button named Add in {name}. | |
empty | ReactNode | A "No issues" empty state | On RecordGroup. Shown when count is 0. |
id | string | On RecordRow. The row's identity, shown at its start and passed to onOpen. | |
title | ReactNode | On RecordRow. The row's text, or any node. It truncates. | |
statusIcon | ReactNode | On RecordRow. Drawn before the title. | |
labels project assignee dueDate | ReactNode | On RecordRow. The right-hand slots. | |
display | RecordDisplaySlots | All four on | On RecordRow. Which slots render. |
trailing | ReactNode | On RecordRow. Drawn after the four slots, whatever display says. | |
openable | boolean | true | On RecordRow. false keeps the look and drops hover, selection, open, and the arrow keys, and the row then works outside RecordList. |
dragHandle | boolean | false | On RecordRow. Draws a grip icon. It does not make the row draggable. |
Accessibility
- The list is a
groupnamed "Records", and each group's rows are alistboxnamed for the group, with the heading, the create button, and the empty state outside it. Pass your ownaria-labelto 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 anoptionwitharia-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 instatusIconor the right-hand slots stays with it and does not open the row. - A row with
openable={false}has no role, noaria-selected, and no place in the arrow-key order. Put such rows in a plain container rather than aRecordGroup, 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-selectedand drawn inbg-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
menuitemcheckboxnamed by its label, witharia-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
useRecordListVirtualizeronly as an empty stub.
Install
shadcn add @gesso/record-list