Gesso
Data display

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_8412Dana Okafordana@example.comActive12Mar 14, 2026
usr_8417Marco Reyesmarco@example.comInvited3May 2, 2026
usr_8433Priya Ramanpriya@example.comActive48Jun 21, 2026
usr_8440Sam Whitfieldsam@example.comSuspended1Jul 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 subscriptionsIn progressHigh$4,200.00Oct 14, 2026example.com/audit
Migrate admin tableTodoLowEmptyEmptyEmpty
  • 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. numberFormat shapes the display in en-US: plain and integer group digits (and integer rounds), currency is US dollars, and percent appends 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-DD string, 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.

PropTypeDefaultWhat it does
fieldFieldDefThe column being rendered.
valueFieldValueThe cell's current value.
onChange(next: FieldValue) => voidCalled with the committed value.
editingbooleanControls whether the editor is open. Omit it and the cell holds its own state.
onEditingChange(editing: boolean) => voidCalled when the cell opens or closes its editor.
classNamestringMerged 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 rootProps on, and the layer adds no role. A <td> remains a table cell, and there is no grid or gridcell role, 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 listbox named 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: sortable plus aria-sort on 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. Input is 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 off data-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 overflow ancestor. Table wraps 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

On this page