Skip to content
Component installs need the registry setup
VegaStack Design

Editable Cell

Inline-editable value whose edit mode looks like view — optimistic saves, a delayed spinner, rollback with a Retry toast, and table-cell and heading variants.

Status
stable
Since
0.4.0
Accessibility pattern
button-to-editor disclosure

Last updated

Account nameAcme Corporation

Click, or Tab then Enter or F2. Only the caret shows it is editing. Enter or blur saves; Escape cancels.

Install

Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for Editable Cell before installation. Retain the digest printed by this command.

pnpm exec vegastack-design verify editable-cell

Install with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.

pnpm dlx shadcn@4.21.0 add @vegastack/editable-cell

Run the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.

The same command installs the registry items it composes: @vegastack/auto-save-input, @vegastack/button, @vegastack/date-picker, @vegastack/select, @vegastack/spinner, @vegastack/toast, @vegastack/use-announcer, @vegastack/use-inline-edit, @vegastack/use-overflow, @vegastack/date-time.

Usage

import { EditableCell } from "@/components/ui/editable-cell";

<EditableCell
  value={deal.name}
  label="Deal name"
  onSave={(name) => api.updateDeal({ name })}
/>;

Edit looks like view. There is no border, ring or background change when the cell edits: the field inherits the font, size, weight, line height, tracking and colour of its surroundings, and only the caret and the selection show it is being edited. The display and the field share one box — a grid cell holding the value text with the field laid over it — so the field grows with its content, wraps when the value wraps, and entering or leaving edit changes neither the width nor the line box.

Saving. Blur or a click outside saves; Enter saves a single-line value and

⌘/Ctrl+Enter a multiline one; Escape cancels and restores. Nothing is saved when the value is unchanged. When onSave returns a promise the display updates at once; a subtle spinner trails the value only if the save takes longer than 300ms; on failure the value rolls back, the failure is announced and a toast offers Retry (the app mounts Toaster). onCommit is the older name of onSave and still works.

Affordance. No fill at rest, on hover or while editing — the text cursor and an optional tooltip are the only pointer affordances. Keyboard focus on the resting value shows the system's focus tint; Tab reaches the cell and Enter or

F2 starts editing.

In a property list. Inside a PropertyValue the inline cell's box turns compact — 6px and 2px of padding hung outside by equal negative margins — so its text lines up exactly (left edge, baseline, row height) with the read-only values beside it, in every state; the box keeps its hit area but paints no fill. See Property List.

Scope

BehaviourWhere it lives
Persistence, conflict checkHost — onSave fires once per changed value; a rejected promise rolls back and offers Retry
Debounced auto-save while typingAutoSaveInput
The grid's roving focus modelThe grid host — focusMode="managed" removes this tab stop; onNavigate reports Enter and Tab
Date / actor / currency editorsApps, via the open custom editor contract

Anatomy

EditableCell — data-slot="editable-cell" | "editable-cell-status"
EditableCellRest — data-slot="editable-cell-display" | "editable-cell-text"
InlineEditTrigger — data-slot="inline-edit-trigger"

Examples

Table cell

variant="cell" fills the table cell, so the field is as wide as the column and the column never resizes. Only the text starts an edit: a click beside it goes to the row (a row link keeps working). With onNavigate, Enter and Tab save and hand the host a direction to move to the next cell (Shift goes back).

DealOwnerAmount
Acme renewalAsha Rao$12,400
Globex pilotKiran Mehta$4,800
Initech expansionAsha Rao$31,000

Page title

variant="heading" keeps the heading's size and weight and wraps naturally, in view and in edit. It has no box padding, so the title is edited exactly where it is rendered — idle and editing sit at the same position, lined up with the lines below, and only the text cursor marks it editable. Put the cell inside the heading element rather than restyling its parts.

Give a stored name its limit with editor={{ type: "text", maxLength }}: the field stops there, a paste keeps what fits and says so, and a quiet n/max count shows under the field from 90% of the limit — so what is typed or pasted never runs past what the store accepts. (A value that is already longer is shown and kept as it is; maxLength only limits what is added.)

<h1 className="text-2xl font-semibold">
  <EditableCell
    variant="heading"
    required
    label="Meeting title"
    value={title}
    onSave={rename}
  />
</h1>

Weekly product sync with the platform, design and customer success teams

Tuesday, 10:00 · 45 min

Single-line

The default. A long value truncates with an ellipsis and offers the full value as its title; opening the editor shows all of it. wrap breaks it onto more lines instead.

Account nameAcme Corporation

Click, or Tab then Enter or F2. Only the caret shows it is editing. Enter or blur saves; Escape cancels.

Multiline

multiline edits several lines: Enter adds a line, ⌘/Ctrl+Enter or blur saves.

NotesWants a pilot in Q3. Legal review pending — follow up Friday.

Required

With required, clearing the value saves nothing: the previous value comes back and requiredMessage (default {label} can't be empty) is announced and toasted.

Company (required)Northwind Traders

Clear it and press Enter: the old value comes back.

Save error

A rejected onSave rolls the value back, announces it and raises an error toast whose Retry runs the same save again. saveErrorToast={false} leaves the toast to the host.

Deal amount (the server always rejects)$12,400

Every save fails: the value rolls back, the failure is announced and a toast offers Retry.

Slow save

The value updates optimistically; the trailing spinner appears only after 300ms, so fast saves never flash it.

Project (2s save)Q3 planning

Parity

The same value in view and in edit mode, over a 20px baseline grid: the text sits on the same line, at the same x, with the same width.

Quarterly business review
View box:

Whole-cell triggers

In a list, the WHOLE cell is the click target — a priority's flag and its text, a status, an assignee, a date — never just the icon. The date and select editors already fill a cell variant cell; an app-owned editor (a status, priority or assignee menu) puts InlineEditTrigger behind its DropdownMenuTrigger or PopoverTrigger. Both carry no border at rest and the properties-panel tint on hover, keyboard focus and while open, and both open with

Enter or Space. Mark the column interactive, and render a plain value where the viewer may not edit.

TaskPriorityDue
Send the lighting schedule

Select editor

Real inline editors are per-type: a stage field edits with a popover list, not a text box. The select editor renders a Select whose popover is the editor; the save fires on selection.

Stage

Number editor

editor={{ type: "number", unit, min, max, step }} edits a spec value. The value is still a string: it displays formatted in the reader's locale with the unit muted after it (1,250 mm), and edits as the raw number with a decimal keyboard and the unit trailing the caret inside the same box. A draft that is not a number, falls outside min/max or is off step does not save: the field stays open with the draft, marked aria-invalid, and the rule is announced. Grouping (1,250) is accepted and saved without it; an empty draft saves "".

Below, each editor type inside a PropertyList variant="inline" — text, number with a unit, and select — lines up with the read-only value above it at rest, on hover and while editing.

SKU
RL-26-104
Name
Nova Pendant
Width
1,250mm
Weight
Add weight
Finish

Custom editor with a label

When the value is an id — an owner, a customer, a project — pass renderValue so the cell shows the name a person reads instead of the stored id. It is used while displaying, for every editor type; the editor still works on the raw value, and an empty value keeps the placeholder.

Asha Rao

States

Read-onlyACME-2041
DisabledNorthwind Traders
Saving (controlled status)Renewal 2027

Managed focus (grid hosts)

Inside a grid the grid's roving focus model owns reachability. focusMode="managed" removes the cell's own tab stop, and the host opens the editor from its keyboard model through the controlled editing / onEditingChange pair:

<EditableCell
  variant="cell"
  value={cell.value}
  label={column.header}
  focusMode="managed"
  editing={isEditingThisCell}
  onEditingChange={(editing) => grid.setEditingCell(editing ? cellId : null)}
  onNavigate={(direction) => grid.moveEditor(direction)}
  status={grid.cellStatus(rowId, column.key)}
  onSave={(next) => grid.commitCell(rowId, column.key, next)}
/>

API Reference

PropTypeDefaultDescription
value*string—The persisted value. The cell displays it, edits a draft of it, and reverts to it on a failed commit.
classNamestring—Extra classes merged onto the cell root.
disabledbooleanfalseBlocks editing; the display is dimmed and out of the tab order.
editingboolean—Controlled edit mode, forwarded to the underlying editor. Required in practice for managed hosts (the grid opens the editor on Enter/F2); omit for the built-in click / <kbd>Enter</kbd> / <kbd>Space</kbd> activation.
editorEditableCellEditor{ type: "text" }The editor to open. See EditableCellEditor.
editSeedstring—The draft the text and number editors start from when a host opens them through editing, instead of value — type-to-edit, where the printable key that opened the editor replaces the value. A custom editor receives it as seed. Ignored by the date and select editors and by a click or Enter open.
emptyTextstringthe editor's `placeholder`, else "Edit value"What an EMPTY value shows at rest (text, number and custom editors). "" shows nothing — a grid's empty cells stay blank and the editor's placeholder appears only once the cell edits.
flushbooleanfalsePull the display box back by its own inline-start padding, so the value's text lines up with the lines above and below it (a page title over its description). The box (and its hit area) extends into the gutter instead; the text does not move when the editor opens.
focusMode"managed" | "standalone""standalone"Focus policy. standalone (a card, a property list) gives the cell its own tab stop. managed removes it — the host's roving focus model owns reachability and opens the editor through editing/onEditingChange.
labelstring—Accessible name for the value being edited (e.g. "Deal amount"). Falls back to the placeholder, then to a generic phrase; the editor is never unnamed.
multilinebooleanfalseEdit several lines. The field wraps, <kbd>Enter</kbd> inserts a newline and <kbd>⌘</kbd>/<kbd>Ctrl</kbd>+<kbd>Enter</kbd> saves.
onCommit((next: string) => void | Promise<void>)—The original name of onSave, kept so existing hosts (the DataGrid) keep working. onSave wins when both are passed.
onEditingChange((editing: boolean) => void)—Called when the cell wants to enter (true) or leave (false) edit mode.
onNavigate((direction: "next" | "previous", via: "tab" | "enter") => void)—Table navigation. When set, <kbd>Tab</kbd> / <kbd>Shift</kbd>+<kbd>Tab</kbd> and (single-line) <kbd>Enter</kbd> / <kbd>Shift</kbd>+<kbd>Enter</kbd> save and then call it, so the host can move to the next or previous cell. via says which key did it, so a grid moves down on <kbd>Enter</kbd> and across on <kbd>Tab</kbd>. A custom editor reaches it through commit(next, move).
onSave((next: string) => void | Promise<void>)—Save callback. Return a promise to engage the async layer: the display updates optimistically, a subtle trailing spinner appears if the save takes longer than 300ms, and on reject the value **rolls back to value**, the failure is announced, and an error toast offers Retry (see saveErrorToast). Return void for synchronous hosts. Fires only when the value actually changed.
previousStatusAutoSaveStatusthe first `status`The status before this cell mounted, for a host that mounts it in the middle of a change (a grid's resting cell becoming the full one when its status moves): a move from it to status is announced as a live change would be.
readOnlybooleanfalseRenders the value as plain non-interactive text with no edit affordance.
refReact.Ref<HTMLSpanElement>—Ref forwarded to the cell's root <span> (data-slot="editable-cell").
renderValue((value: string) => React.ReactNode)—What display mode shows for a non-empty value — for a value that is an id ("u_7"), the label a person reads ("Asha Rao"), or any rich node (an avatar and a name). Used by every editor type while displaying; editing still works on the raw value, and an empty value keeps the placeholder.
requiredbooleanfalseAn empty value is not saved: the previous value is restored and requiredMessage is announced and toasted.
requiredMessagestring"{label} can't be empty"The message shown when a required value is cleared.
saveErrorToastbooleantrueRaise an error toast with a Retry action when onSave rejects. false leaves the failure to the announcement and the rollback (a grid that reports errors itself).
statusAutoSaveStatus—Controlled status override. Omit it to let the cell derive status from the onCommit promise; pass it when the host owns the write lifecycle (a grid's cellStatus). Uses AutoSaveStatus — the system's one vocabulary for async field writes.
tooltipstring—A hint shown on hover while displaying (the native tooltip), e.g. "Click to rename". A truncated value shows the full value instead.
variant"cell" | "heading" | "inline""inline"Layout of the text editor. inline sizes the box to its value; cell fills the table cell (the row link still takes clicks outside the text); heading keeps the surrounding size and weight, wraps naturally and has no box padding — a page or record title edited in place, idle and editing at the identical position.
wrapbooleanfalseLet a long value wrap onto more lines instead of ending in an ellipsis — a page title that must be read whole. The display grows taller; the editor is unchanged.

Data attributes and CSS variables on EditableCell

AttributeValues
data-flush""
data-focus-modemirrors a prop or state value
data-slot"editable-cell" | "editable-cell-status"
data-statusmirrors a prop or state value
data-variantmirrors a prop or state value
data-wrap""

InlineEditTrigger

PropTypeDefaultDescription
layout"cell" | "chip""cell"cell fills its container — a list cell, where the WHOLE cell (icon and text) opens the editor, its padding hung outside so the value's text starts where a plain value does. chip sizes to its value — a board card field.
loadingboolean—Shows a spinner over the label, blocks activation and sets aria-busy. The label keeps its box at opacity: 0, so the button's width does not move and its accessible name survives (API-5, A11Y-12).

Data attributes and CSS variables on InlineEditTrigger

AttributeValues
data-layoutmirrors a prop or state value
data-slot"inline-edit-trigger"

EditableCellRest

What DataGrid renders for a resting cell whose column has a custom editor: the display's button and value, accessible name and description, and the same box, with no state, effects or live region. A click on the value (not on a link chip inside it) or Enter, F2 or Space on it calls onStartEditing; the grid then mounts EditableCell for that cell. A clipped value gets its full text as the title when the pointer or focus reaches it.

PropTypeDefaultDescription
onStartEditing*() => void—The reader asked to edit: a click on the value, or Enter, F2 or Space on the display.
value*string—The persisted value, as text.
classNamestring—Extra classes for the root.
emptyTextstring"Edit value"What an empty value shows ("": nothing), as EditableCell's emptyText.
labelstring"value"The field's name, for the display's accessible name ("Edit Stage").
refReact.Ref<HTMLSpanElement>—The display element (a callback ref may return a cleanup).
renderValue((value: string) => React.ReactNode)—Renders a non-empty value (tags, chips, avatars) instead of its text.

Data attributes and CSS variables on EditableCellRest

AttributeValues
data-rest""
data-slot"editable-cell-display" | "editable-cell-text"
data-variant"inline"

Editor contract

The custom editor receives EditableCellEditorProps: the value, the seed (the printable key that opened it, through editSeed), commit(next, move?) and cancel. A move — down, up, right or left — is handed to onNavigate after the commit, so an editor follows the spreadsheet keys (Enter down, Shift+Enter up, Tab right,

Shift+Tab left). An editor that saves a typed value itself leaves the cell with commit(value, move): an unchanged commit saves nothing.

PropTypeDefaultDescription
cancel*() => void—Leave edit mode without committing.
commit*(next: string, move?: EditableCellMove) => void—Commit next and leave edit mode, then — with move — hand the move to the host's onNavigate, so a grid steps to the next cell. No-ops the async layer when unchanged, so an editor that saves a typed value itself leaves (and moves) with commit(value, move).
value*string—The value being edited (the optimistic value while a commit is in flight).
seedstring—The printable character that opened the editor (type-to-edit, the cell's editSeed), or undefined for a click, <kbd>Enter</kbd> or <kbd>F2</kbd> open. A text-like editor starts its draft from it, replacing the value; a picker starts its search from it.

Accessibility

  • Read mode is a role="button" labelled Edit {label}, described by the value; edit mode is a text field labelled label (then placeholder, then a generic name).
  • Saves and errors are announced through a visually hidden polite live region — "Saved", Couldn't save {label} — value restored, and the required message. The spinner is decorative.
  • Escape cancels, restores the prior value and returns focus to the display.
  • In focusMode="managed" the cell has no tab stop of its own — the host grid owns reachability. Inside DataGrid its announcements are spoken through the grid's one live region rather than a region per cell.
KeyAction
Enter / F2 / SpaceStart editing from the focused display.
EnterSave a single-line value (and move on, with onNavigate).
⌘/Ctrl+EnterSave a multiline value.
TabWith onNavigate: save and move to the next cell.
Shift+TabWith onNavigate: save and move to the previous cell.
EscapeCancel and restore the prior value.
ContractStates tested
Behaviourdisplay, editing, saving, saved, error, disabled, read-only, managed-focus
Accessibilitykeyboard, labeled, status-announcement, semantic-html
Visualdefault, hover, saving, saved, error, disabled

Do / Don't

Do
Return a promise from onSave so the cell can update optimistically, roll back on rejection and offer Retry.
Don't
Style the cell — no width, border, padding or background overrides. Put it inside the heading or table cell and let it inherit.

On this page