Cell fields
Render and edit one table cell from a column descriptor and a value.
Cell fields is the part of a table that knows how a value looks and how it is edited. Hand it a column descriptor and a value and it gives you back the display and the editor, and that is the whole contract. It has no idea what a row is, or a table, or where the data came from, so whatever already renders your rows can use it.
Pair it with Table for a normal table, or with TanStack Table, or with your own markup. The editable grid is an example of pushing it to spreadsheet density.
| usr_8412 | Dana Okafor | dana@example.com | Active | 12 | Mar 14, 2026 |
| usr_8417 | Marco Reyes | marco@example.com | Invited | 3 | May 2, 2026 |
| usr_8433 | Priya Raman | priya@example.com | Active | 48 | Jun 21, 2026 |
| usr_8440 | Sam Whitfield | sam@example.com | Suspended | 1 | Jul 9, 2026 |
Click a name, a status, a seat count, or a date. ID and email render through the same registry but never wire an editor, which is the usual shape of admin editing: a lot of columns, and a few that accept input.
Usage
import { useFieldCell, type FieldDef, type FieldValue } from '@/components/data/cell-fields';
import { TableCell } from '@/components/ui/table';
const column: FieldDef = { id: 'seats', name: 'Seats', type: 'number' };
function EditCell({
column,
value,
onChange,
}: {
column: FieldDef;
value: FieldValue;
onChange: (next: FieldValue) => void;
}) {
const { rootProps, children } = useFieldCell<HTMLTableCellElement>({
field: column,
value,
onChange,
});
return <TableCell {...rootProps}>{children}</TableCell>;
}The barrel also exports getFieldSpec and FIELD_SPECS for reading cells,
FIELD_CELL_CLASSNAME, CategoryTag, CellPopover, PopoverListbox, and PopoverOption for building your
own editors, and CAT_COLORS, catChipStyle, and catDotStyle for the categorical
palette. Importing a type through the barrel pulls the whole registry (the specs, Phosphor,
and Badge) into the graph, so a type-only consumer should import from
@/components/data/cell-fields/types directly.
A column is a FieldDef
The descriptor is small, and most columns only need the first three fields. The rest is per-type config, so a column is self-describing and the host never has to look elsewhere to render it.
interface FieldDef {
id: string;
name: string;
type: FieldType;
options?: SelectOption[]; // select
display?: 'pill' | 'dot'; // select
numberFormat?: 'plain' | 'integer' | 'currency' | 'percent'; // number
}FieldValue is a loose union, string | number | boolean | null. Each field type reads only
the shape it owns, and null is the universal empty. An empty cell shows a quiet "Empty".
Six types
text, number, select, date, checkbox, and url are the types a fixed-column table usually has.
Task | Status | Priority | Budget | Due | Billable | Link |
|---|---|---|---|---|---|---|
| Audit subscriptions | In progress | High | $4,200.00 | Oct 14, 2026 | example.com/audit | |
| Migrate admin table | Todo | Low | Empty | Empty | Empty |
- text shows the string and edits it in an input. A blank edit stores
null. - number aligns to the end in tabular figures and edits in a number input. A blank or
non-numeric edit stores
null.numberFormatshapes the display inen-US:plainandintegergroup digits (andintegerrounds),currencyis US dollars, andpercentappends a%to the stored number without scaling it. - select stores an option id. A value that matches no option shows as empty. Picking the
option that is already chosen clears it to
null. - date stores an ISO
YYYY-MM-DDstring, shows it as "Mar 14, 2026" beside a calendar icon, and edits it in the browser's date input. - checkbox stores a boolean, and an empty one is
false. - url stores a string and shows it as a link with the protocol trimmed, opened in a new tab. Clicking the link follows it without opening the editor.
There is one choice type rather than two. A status column and a tag column are the same
data, an id pointing at a named, colored option, so select covers both and display
picks how the chosen option draws:
{ id: 'status', name: 'Status', type: 'select', display: 'dot', options: [...] }
{ id: 'priority', name: 'Priority', type: 'select', options: [...] }A dot keeps the label in body ink and puts the hue in a small circle beside it. A pill, the default, carries the hue as a CategoryTag, which keeps its hue under every look.
The registry extends
Nothing in the layer knows about any specific type. It looks each one up in a registry, and
every type owns three things there: how it draws in a cell, how it is edited, and its icon.
Adding a type is adding one case to FieldType and one spec to the registry, and nothing
else changes. getFieldSpec falls back to the text spec for a type it does not know.
export interface FieldSpec {
type: FieldType;
label: string;
icon: Icon;
emptyValue: FieldValue;
align?: 'start' | 'end';
interactiveDisplay?: boolean; // commits without opening an editor
Display: React.FC<FieldDisplayProps>;
Editor?: React.FC<FieldEditorProps>;
}A Display takes { value, field, onChange? } and an Editor takes
{ value, field, onChange, onClose }. Neither one reaches for anything larger than the one
column it was handed, which is what makes the layer portable. If your app needs a type the
kit does not ship, such as a currency picker or a relation to another table, that is a spec
you add and not a fork.
Reading a cell
For a column that never edits, take the spec off the registry and render its display.
import { getFieldSpec } from '@/components/data/cell-fields';
const spec = getFieldSpec(column.type);
<td><spec.Display value={value} field={column} /></td>Editing a cell
useFieldCell owns the display-to-editor swap, the click and Enter affordance, and the
toggle-in-place case. It returns props and children rather than an element, so they go onto
whatever cell your table already renders, as in Usage above.
rootProps carries data-slot="field-cell" and data-mode, which is read, edit, or
interactive. Focus and edit chrome ship on those attributes: a read cell is a
tabIndex={0} target with an inset ring, and an open editor paints ring-primary on the
same element. Hosts override layout such as padding and height, and should merge with
cn(rootProps.className, ...) if they pass className after the spread. There is no
component wrapper on purpose. The element is the host's call, a <td> or a <div> in a
card list, and a wrapper would only guess at it.
Pass editing and onEditingChange to hold the state yourself, which is what you want if
only one cell may be open at a time. Leave them off and each cell keeps its own.
Nothing moves when you click in
An input carries its own intrinsic width, twenty characters by default, and on number and
date the size attribute does not apply. Dropped into a table that sizes columns to their
content, that widens the column the instant the editor mounts and shoves every other column
sideways, right under the cursor that clicked it.
So the editor is absolutely positioned over the cell's content box, and the display stays
underneath it as an invisible sizer holding the cell at exactly the width and height it
already had. The text input has no px-* of its own, so a p-4 TableCell and a dense
px-2 grid keep their text where it was. That is also why the editor fills its cell
exactly rather than floating at a fixed height inside a taller one.
Toggle in place
A checkbox sets interactiveDisplay, which means its display is itself the control. The
mode is interactive, no editor ever opens, and the change commits on the click. A host
that special-cases mode === 'edit' gets this for free without knowing the type. The cell
does not become a tab stop in this mode, and the checkbox inside it is the one control.
Props
useFieldCell<T>(options) takes these options.
| Prop | Type | Default | What it does |
|---|---|---|---|
field | FieldDef | The column being rendered. | |
value | FieldValue | The cell's current value. | |
onChange | (next: FieldValue) => void | Called with the committed value. | |
editing | boolean | Controls whether the editor is open. Omit it and the cell holds its own state. | |
onEditingChange | (editing: boolean) => void | Called when the cell opens or closes its editor. | |
className | string | Merged after the default field-cell chrome. Layout only. |
It returns { mode, rootProps, children }. Spread rootProps on the cell element and render
children inside it.
Accessibility
- The cell stays whatever element you put
rootPropson, and the layer adds no role. A<td>remains a table cell, and there is nogridorgridcellrole, so assistive tech does not announce a spreadsheet. - Every read-mode cell is
tabIndex={0}, so each editable cell is its own Tab stop. There is no arrow-key movement between cells, and a table with many editable columns has many tab stops. A checkbox cell is not a stop itself, and the checkbox inside it is. - Enter on a focused cell, or a click, opens its editor. Space does not.
- In a text, number, url, or date editor Enter commits and closes, a blur commits, and Escape closes without committing, so a date picked and then dismissed with Escape is not kept. The select editor commits when an option is chosen and closes, and Escape, Tab, or a click outside closes it with no change.
- Focus returns to the cell when an editor closes, so a keyboard user is not dropped on the page. If you click straight into another cell, focus stays there.
- Read focus is a 2px inset ring on the cell, and an open editor is a 2px ring in
ring-primary. In forced colors the ring drops and a focused cell shows a system outline instead, and an idle cell shows none. There is no 44px minimum, and the checkbox is 18px. - Each editor is named by its column, like "Name", and so is a checkbox cell, so a screen reader hears the column when an editor opens.
- Nothing is announced when a value is committed. The cell simply shows the new value.
- The select editor's options are a
listboxnamed by the column. Focus moves to the chosen option, or the first, when it opens, the arrow keys, Home, and End move between options, and Enter or Space picks one. - A url cell's link is a second tab stop inside the cell, and a click or Enter on it follows the link. Enter on the cell itself opens the editor.
- Sortable heads are a separate contract on Table:
sortableplusaria-sorton the<th>, and a real button that takes keyboard focus. - The editors use raw inputs rather than the kit's Input and
Select, and that is deliberate.
Inputis 40px with its own border, radius, and focus ring, while a dense cell is 36px and already owns its ring. The cell owns the chrome, one inset ring at the cell boundary offdata-slot="field-cell", and the editor is full-bleed inside it with no second ring. - That popover clips. It is dependency-free, positioned under its cell, and an absolutely
positioned panel cannot escape an
overflowancestor.Tablewraps itself in one, so an option list that runs past the bottom of the table is cut off rather than drawn over it. Moving onto Popover, which portals and knows about collisions, would fix it and is the open follow-up. Until then, give a table with editable select columns room underneath. - The popover is at least 220px wide, which forces horizontal scroll in a very narrow column.
When to use something else
- Table when the values are only read. It is the markup this page edits inside, and it needs no field layer.
- Record list for grouped records opened one at a time, where editing happens in a panel and not in the row.
- Description list for the facts about one record, with an action beside each that jumps to where it is edited.
- Field with an Input for a value edited in a form, with a label over each control.
- A data grid library when you need spreadsheet behavior: arrow-key movement between cells, range selection, virtualized rows, copy and paste, or columns the reader adds or retypes. Cell fields edits one cell at a time and ships none of that, and the columns here are a fixed array.
Install
shadcn add @gesso/cell-fields