InlineEdit
Edit a value in place — it reads as plain text until you touch it, then becomes a real field with commit, cancel, and validation.
@garn/ui/inline-editOn this page
Default
The default — a controlled value, click to edit, Enter/blur to commit.
Presets
Quiet (Enter/blur, no buttons) vs explicit (Save/Cancel) — one API.
Activation
click · dblclick · none (EditTrigger only).
Multiline
An auto-growing textarea — Enter inserts a newline, ⌘/Ctrl+Enter saves.
Empty
An empty value shows the muted placeholder and still reads as editable.
Max Length
A capped value with a live character counter.
Async
An async onSubmit — a spinner while saving, stays open with the error on failure.
Affordance
A hover-revealed edit pencil (EditTrigger `reveal`) for discoverability.
Validation
required + validate gate a commit with an inline error before it persists.
Optimistic
Optimistic commit — closes immediately, rolls back and reopens on failure.
Typed
A NumberField edit-state control wired through the useInlineEdit() hook.
Sizes
sm · md · lg.
Appearances
ghost (text until touched) · outline · soft.
Form
Form integration — `name` submits the committed value via a hidden input.
Data Rich
InlineEdits inside a DataList — an editable detail panel.
Installation
garn is copy-in — the garn CLI writes this component's source into your project, so you own and can edit it. It resolves any base files and installs the npm dependencies below for you.
npx garn-ui add inline-edit- Registry
inline-edit- Deps
@radix-ui/react-slotlucide-react- Registry deps
utilsuse-controllable-stateuse-announceuse-isomorphic-layout-effectfield-variantsspinner
Import
import { InlineEdit.Preview, InlineEdit.Control, InlineEdit.EditTrigger, InlineEdit.SubmitTrigger, InlineEdit.CancelTrigger } from "@garn/ui/inline-edit";Anatomy
The parts this component is built from — compose them to assemble it.
InlineEdit.PreviewRequired[data-slot="inline-edit-preview"]The read node — a text-styled <button> shown when not editing. Its accessible name composes '{editLabel}, {value}' so a SR user knows it's editable. Click/focus/dblclick activates per activationMode. Falls back to the committed value when given no children; asChild to host a heading/link.
InlineEdit.ControlRequired[data-slot="inline-edit-control"]The edit node — shown when editing. With no children it renders the built-in text field (an <input>, or a <textarea> when multiline) auto-wired to the draft on the field surface. Pass a custom field (NumberField / Select) as children and wire it via useInlineEdit().
InlineEdit.EditTrigger[data-slot="inline-edit-edit-trigger"]Optional pencil affordance shown when not editing — enters edit mode. Use with activationMode="none", or set reveal to fade it in on hover/focus-within (the DataList.CopyButton idiom) as a discoverability hint.
InlineEdit.SubmitTrigger[data-slot="inline-edit-submit-trigger"]Optional commit button (a check) shown when editing. Its presence turns the quiet preset into the explicit one — no boolean needed. Shows a Spinner while an async commit is pending.
InlineEdit.CancelTrigger[data-slot="inline-edit-cancel-trigger"]Optional revert button (a ✕) shown when editing — reverts to the last committed value and returns focus to the Preview.
Props
The component's public props and their types.
activationMode- Description
- How the Preview enters edit mode. `none` = only via EditTrigger / programmatic.
- Type
"click" | "dblclick" | "focus" | "none"- Default
"click"
appearance- Description
- Field-surface fill. `ghost` reads as plain text until touched; `outline` / `soft` render a visible field at rest. ⚠️ non-ghost blurs the "text-until-touched" identity — use deliberately.
- Type
"ghost" | "outline" | "soft"- Default
"ghost"
size- Description
- Field-surface size scale.
- Type
"lg" | "md" | "sm" | "xl" | "xs"- Default
"md"
submitMode- Description
- What commits the draft. `both` = Enter or blur; `none` = only SubmitTrigger.
- Type
"blur" | "both" | "enter" | "none"- Default
"both"
cancelLabel- Description
- Accessible name for the default CancelTrigger.
- Type
string- Default
"Cancel"
defaultEditing- Description
- Whether the editor starts open (uncontrolled).
- Type
boolean
defaultValue- Description
- The initial committed value (uncontrolled).
- Type
string
disabled- Description
- Disable the whole control.
- Type
boolean- Default
false
editing- Description
- Whether the editor is open (controlled). Pair with `onEditingChange`.
- Type
boolean
editLabel- Description
- Accessible name for the default EditTrigger + the Preview's edit affordance.
- Type
string- Default
"Edit"
error- Description
- A validation / commit error; keeps the editor open and wires aria-invalid + the message.
- Type
React.ReactNode
loading- Description
- External pending state (a controlled async commit).
- Type
boolean- Default
false
maxLength- Description
- Cap the draft length; renders a live character counter in edit mode.
- Type
number
multiline- Description
- Edit as a multi-line, auto-growing textarea (Enter inserts a newline; ⌘/Ctrl+Enter submits).
- Type
boolean- Default
false
name- Description
- Submit the committed value inside a native `<form>` via a hidden input. Reflects the committed (not draft) value.
- Type
string
onCancel- Description
- Fires when the user reverts (Escape / Cancel / revert-on-blur).
- Type
() => void
onEditingChange- Description
- Fires when the editor opens or closes.
- Type
(editing: boolean) => void
onSubmit- Description
- Attempt to commit `value`. Return a promise to run async: the editor shows a spinner and stays open until it resolves; a rejection surfaces its message as the error and keeps the editor open. Resolving (or returning void) commits.
- Type
(value: string) => void | Promise<void>
onValueChange- Description
- Fires with the new committed value after a successful submit.
- Type
(value: string) => void
optimistic- Description
- Commit the new value optimistically — fire `onValueChange` immediately, and if an async `onSubmit` rejects, roll the value back to what it was before. Pessimistic when `false`: the value only changes after `onSubmit` resolves.
- Type
boolean- Default
false
placeholder- Description
- Shown in the Preview when the value is empty (e.g. "Add description").
- Type
React.ReactNode
required- Description
- Block committing an empty value (after trimming). Sugar over `validate`.
- Type
boolean- Default
false
requiredMessage- Description
- Error shown when `required` blocks an empty commit.
- Type
string- Default
"Required"
savedAnnouncement- Description
- Announced (polite) after a successful commit.
- Type
string- Default
"Saved"
selectOnFocus- Description
- Select the field's text when edit mode opens.
- Type
boolean- Default
true
submitLabel- Description
- Accessible name for the default SubmitTrigger.
- Type
string- Default
"Save"
validate- Description
- Gate a commit *before* it persists. Return a message (string) to block the commit and show it as the inline error; return null / undefined to allow it. May be async (the editor shows a spinner while it runs). Distinct from `onSubmit`: `validate` gates, `onSubmit` saves.
- Type
(value: string) => string | null | undefined | Promise<string | null | undefined>
value- Description
- The committed value (controlled). Pair with `onValueChange`.
- Type
string
Plus 277 inherited native <span> attributes.
InlineEdit.Preview
asChild- Description
- Render as the child element (e.g. a heading), merging the trigger wiring.
- Type
boolean
label- Description
- Override the composed accessible name. By default the button reads "{editLabel}, {value}" (e.g. "Edit, Ada Lovelace") so a SR user knows the value is editable; pass `label` to set it explicitly.
- Type
string
Plus 289 inherited native HTML attributes.
InlineEdit.Control
children- Description
- A custom edit-state field (a NumberField / Select), wired via `useInlineEdit`. Omit to render the built-in text input (or textarea when `multiline`), which is auto-wired to the draft.
- Type
React.ReactNode
className- Description
- No description.
- Type
string
InlineEdit.EditTrigger
reveal- Description
- Fade the affordance in on the root's hover / focus-within (and keep it visible on touch), so a plain-text value visibly reads as editable without cluttering the resting state.
- Type
boolean- Default
false
Plus 290 inherited native HTML attributes.
Styling
Target these data-slots and states, and remap these tokens, to restyle without forking the component.
[data-slot="inline-edit"]States
| Selector | State | Description |
|---|---|---|
[data-slot=inline-edit][data-editing] | editing | The editor is open — Preview is replaced by the Control (+ any Submit/Cancel triggers); the field is focused with its text selected. |
[data-slot=inline-edit-preview][data-empty] | empty | The value is empty — the Preview shows the muted `placeholder` and is named by `editLabel` so it still reads as editable. |
[data-slot=inline-edit][data-invalid] | invalid | A commit was blocked (required / validate / async rejection) — the field is aria-invalid, described by the error, and the editor stays open. |
[data-slot=inline-edit-input][aria-busy=true] | pending | An async commit (or external `loading`) is in flight — the field is aria-busy and disabled; a SubmitTrigger shows a Spinner. |
| Property | Token | Tier |
|---|---|---|
height | --garn-control-h-md | semantic · air |
paddingInline | --garn-control-px-md | semantic · air |
When to use
Editing a single value in place — a field in a detail panel, a table/board cell, an entity property, or a title you rename without a modal. Use the quiet preset for fast edits, the explicit preset (Save/Cancel) for costly-to-mistake data cells.
Reach for something else when
- Picking a value from a known set (use BadgeSelect)
- a standard always-open form field with a label (use Input / a field)
- a read-only 'field: value' block (use DataList)
- a whole multi-field form (use Form + inputs)
- rows × columns of records (use Table).
Overview
InlineEdit turns a read-only value editable in place: it reads as plain text until you touch it, then becomes a real field you change without leaving the row. It owns the transition (activation, commit/cancel, focus return, select-on-activate, the save/error announce) and delegates the editing to a real garn field on the shared surface. Compose just InlineEdit.Preview + InlineEdit.Control for the quiet preset (Enter/blur commits), or add InlineEdit.SubmitTrigger / InlineEdit.CancelTrigger for the explicit preset — one API, no fork. For picking a value from a known set use BadgeSelect; for an always-open form field use Input; for a read-only detail block use DataList.
Guidelines
Pick the commit model for the context. The quiet preset (Enter/blur commits, no buttons) suits fast, low-stakes edits; the explicit preset (Save/Cancel) suits data cells where an accidental commit is costly. submitMode and the optional trigger parts express both on one component.
Gate before you persist. Use validate (and required) to block bad input with an inline error — distinct from onSubmit, which persists. An async onSubmit keeps the editor open on failure and surfaces the rejection message; add optimistic when a snappy save matters more than waiting.
Keep it discoverable and accessible. A plain-text value gives no signal it's editable — pair the Preview with a reveal EditTrigger, or use appearance="outline" where a field affordance is wanted. The Preview names itself 'Edit, {value}' so assistive tech announces editability; keep a custom Control field labelled.
Best practices
- Compose Preview + Control for the quiet preset; add SubmitTrigger + CancelTrigger for the explicit one.
- Use
validate/requiredto gate a commit with an inline error, andonSubmit(which may be async) to persist. - Wire a custom typed field (NumberField / Select) through
useInlineEdit()inside InlineEdit.Control. - Pair the Preview with a
revealEditTrigger (orappearance="outline") so the value reads as editable. - Let the Preview name itself; give a custom Control field its own aria-label.
- Don't use it to pick from a known set — that's BadgeSelect.
- Don't reach for
appearance="outline"/"soft"by default — it blurs the text-until-touched identity and the InlineEdit↔Input line. - Don't re-implement validation around
onSubmit— usevalidate(it blocks and shows the error for you). - Don't hand-manage focus return or blur-commit — the component owns the focus model and the blur-vs-Cancel guard.
Content guidelines
- Set a clear
editLabel('Edit name') — it names the Preview and the field. - Use a specific empty
placeholder('Add a description') so an unset value still invites editing.
Troubleshooting
Clicking the value doesn't enter edit mode.
Cause. activationMode is none (or the InlineEdit is disabled) — the Preview doesn't self-activate.
Fix. Use the default activationMode="click", or add an InlineEdit.EditTrigger for the none case.
A custom NumberField / Select in InlineEdit.Control doesn't update or commit.
Cause. The built-in wiring only covers a native input's onChange; a typed field reports via onValueChange.
Fix. Read useInlineEdit() inside the control and wire draft / setDraft / fieldRef / onFieldKeyDown / onFieldBlur onto the field yourself.
A custom control overflows into / overlaps the Submit / Cancel triggers.
Cause. The custom field has a fixed width (e.g. w-28) wider than the flex-1 control track once the triggers take their share of the row.
Fix. Size the custom control flexibly (w-full) so it fills the control track and leaves room for the triggers; the built-in field already flexes. Widen the InlineEdit root if the field needs more space.
Pressing Enter fires onSubmit even when nothing changed.
Cause. It doesn't — an unchanged value is dirty-checked and closes silently. If you expected a save, the value did change.
Fix. Rely on the dirty-check; only real changes run validate / onSubmit and announce 'Saved'.
Validation runs but the value still saves to the server on error.
Cause. validate and onSubmit are separate: validate gates, onSubmit persists. Persisting inside validate bypasses the gate.
Fix. Return the error message from validate (persist only in onSubmit); a non-null return blocks the commit.
The value reads as plain text — users don't know it's editable.
Cause. The default ghost appearance is intentionally text-like at rest.
Fix. Add an InlineEdit.EditTrigger reveal (a hover pencil), or set appearance="outline" where a field affordance is wanted.
Clicking Save (or Cancel) seems to commit twice / fight the blur.
Cause. It doesn't with the built-in triggers — a blur onto a trigger inside the InlineEdit is guarded. A hand-rolled button outside the component isn't.
Fix. Use InlineEdit.SubmitTrigger / .CancelTrigger (they sit inside the region, so the relatedTarget guard applies).
Accessibility
- Role
button- Focus
- The Preview and the edit-state field are focusable; on activate focus moves to the field (text selected), and on commit/cancel it returns to the Preview. Triggers each carry a 2px focus-visible ring.
Accessibility requirements
Give a custom edit-state field an accessible name (aria-label / aria-labelledby). The built-in field is named from `editLabel` automatically.
when InlineEdit.Control hosts a custom field (children) wired via useInlineEdit, and that field has no aria-label / aria-labelledby
- The Preview is a real <button>; its accessible name composes the edit intent with the value ('Edit, Ada Lovelace') so a screen reader knows the value is editable, not just its text. Override with the Preview `label` prop.
- Focus RETURNS to the Preview on commit or cancel (the #1 defect in homegrown inline-edits); the field's text is selected on activate (opt-out `selectOnFocus`).
- Enter commits per `submitMode`; Escape always reverts and is stopPropagation'd so it doesn't also close an enclosing popover/dialog.
- A blur whose focus lands on the Submit/Cancel button does NOT commit (the relatedTarget guard) — the classic blur-fights-cancel-button trap.
- A blocked commit (required / validate / async rejection) keeps the editor open, sets aria-invalid + aria-describedby to the message, and announces it assertively; a success announces politely via a shared live region.
- `required` sets aria-required on the field; a hidden input (`name`) submits the committed value inside a native <form>.