InlineEdit
Edit a value in place — a value that reads as plain text until you touch it, then becomes a real field. Compound Preview ⟷ Control with click/focus/dblclick activation, quiet or explicit commit, validation, dirty-check, optimistic updates, async save, and a swappable typed control.
@garn/ui/inline-editDefault
The default — a controlled value, click to edit, Enter/blur to commit.
Presets
Quiet (Enter/blur, no buttons) vs explicit (Save/Cancel) — one API.
quiet (Enter / blur commits — no buttons)
explicit (Save / Cancel buttons; blur keeps the editor open)
Activation
click · dblclick · none (EditTrigger only).
click (default)
dblclick
none — explicit 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.
ghost
outline
soft
Form
Form integration — `name` submits the committed value via a hidden input.
Data Rich
InlineEdits inside a DataList — an editable detail panel.
- Name
- Title
- Team
- Not set
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- Source
- inline-edit.tsx
- Deps
@radix-ui/react-slotlucide-react- Registry deps
utilsuse-controllable-stateuse-announceuse-isomorphic-layout-effectfield-variantsspinner
Import
import { InlineEdit } from "@garn/ui/inline-edit";Props
Generated from the component's TypeScript 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` (default) 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
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
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
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
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
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. Default `false` (pessimistic: the value only changes after `onSubmit` resolves).
- Type
boolean
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
requiredMessage- Description
- Error shown when `required` blocks an empty commit.
- Type
string
savedAnnouncement- Description
- Announced (polite) after a successful commit.
- Type
string
selectOnFocus- Description
- Select the field's text when edit mode opens.
- Type
boolean
submitLabel- Description
- Accessible name for the default SubmitTrigger.
- Type
string
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 — the `DataList.CopyButton` reveal idiom.
- Type
boolean
Plus 290 inherited native HTML attributes.
Styling
Target these data-slots and remap these tokens to restyle without forking the component.
[data-slot="inline-edit"]| Property | Token | Tier |
|---|---|---|
height | --garn-control-h-md | semantic · air |
paddingInline | --garn-control-px-md | semantic · air |
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.
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.
Enforceable contracts
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>.
- Status
- Beta
- Version
v0.3.0- Introduced
v0.2.0
garn doesn't yet record a per-release version history. This reflects the component's current lifecycle state; full changelog entries will appear once the metadata pipeline emits a lifecycle.history field.