Skip to content
garn

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.

Betav0.4.2added in v0.2.0@garn/ui/inline-edit
On 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.

terminal
npx garn-ui add inline-edit
First time? Set up garn in your project
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

SelectorState
[data-slot=inline-edit][data-editing]editing
[data-slot=inline-edit-preview][data-empty]empty
[data-slot=inline-edit][data-invalid]invalid
[data-slot=inline-edit-input][aria-busy=true]pending
PropertyTokenTier
height--garn-control-h-mdsemantic · air
paddingInline--garn-control-px-mdsemantic · 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

Do
  • Compose Preview + Control for the quiet preset; add SubmitTrigger + CancelTrigger for the explicit one.
  • Use validate / required to gate a commit with an inline error, and onSubmit (which may be async) to persist.
  • Wire a custom typed field (NumberField / Select) through useInlineEdit() inside InlineEdit.Control.
  • Pair the Preview with a reveal EditTrigger (or appearance="outline") so the value reads as editable.
  • Let the Preview name itself; give a custom Control field its own aria-label.
Don't
  • 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 — use validate (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

warn

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

A1.3.1Info and RelationshipsA2.1.1KeyboardAA2.4.7Focus VisibleA3.3.1Error IdentificationA4.1.2Name, Role, Value
  • 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>.