Composites

Inline Edit

Linear-style edit in place: a value that reads as text until someone clicks it, then becomes an input, then saves on Enter or on the way out. It stays text the rest of the time, which is the point — a list of live inputs looks like a giant form and invites edits nobody meant to make.

Basic usage

value is controlled by your data; onSave receives the new one. The component owns nothing but a draft and a boolean, and while it is not editing it re-syncs the draft from value on every change — so a realtime update from another user lands immediately, but never overwrites a half-typed edit.

Task title

value = "Migrate billing to the new gateway"

The default strings are Vietnamese

InlineEdit is one of the small composites that takes plain string props instead of a labels object, so there is no partial override to lean on — an English UI passes both. placeholder defaults to "Nhấn để nhập…" and ariaLabel to "Sửa tại chỗ". The second is the dangerous one: it is invisible, so the page can look entirely English while every field announces itself in Vietnamese to a screen reader. There is no fallback chain and no way to reach it later — set it at the call site, per field.

Untouched defaults

English strings passed

Return a promise from onSave

onSave is typed (value: string) => void | Promise<void> and the component awaits it. While the promise is pending the input is disabled at 60% opacity and edit mode is held open; only when it resolves does the field drop back to text.

This is the whole reason the promise branch exists. Leave edit mode before the write lands and the read-only state renders the value it was given a moment ago — the user watches their new text flash back to the old one and then flip forward again when the state update arrives. Returning the promise makes that impossible: the swap happens after your value has already changed.

Customer name — save takes 1.2s

idle

When the save fails

A rejected promise leaves the component in edit mode with the draft untouched. That is the deliberate choice recorded in the source: the user does not lose what they typed because the network did. What the component will not do is tell them — pass onError and raise a toast, otherwise the only trace is a console.error nobody reads and a field that quietly refuses to close.

One detail worth knowing: on failure the input is not re-focused, because editing never flipped and the focus effect is keyed to it. If the failed save came from a blur, the caret is now somewhere else and the still-open field is easy to miss — another reason the error path needs a visible signal of its own. In the demo below the first save always fails; the second succeeds.

Order reference

The first save will fail. Your text survives it.

Multiline

multiline swaps the <input> for a three-row <textarea> and, more importantly, changes what Enter means. In a single-line field Enter saves; in a multiline one Enter breaks the line and Cmd/Ctrl+Enter saves. Escape cancels in both. The read-only state changes too — from block truncate to whitespace-pre-wrap, so the line breaks the user typed are still there when the field closes.

Description — Enter breaks the line, Cmd/Ctrl+Enter saves

How the edit is committed

saveOnBlur defaults to true: clicking away saves, the way Linear behaves. Turning it off gives you a form-like explicit commit — but then Enter and Escape are the only ways out, and a user who clicks elsewhere leaves the field open behind them. Pair it with showActions, which adds a ✓ / ✕ pair of ghost icon-sm buttons.

Those two buttons fire on onMouseDown with preventDefault(), not on onClick. It looks like a mistake and is not: mousedown lands before the input blurs, so with saveOnBlur on, a click on ✕ would otherwise have been overtaken by the blur-save it was trying to prevent.

Whichever route it takes, the commit is filtered first. The draft is trimmed, and if it equals the trimmed value the component cancels instead of saving — so tabbing through a table of inline fields does not fire a write per field.

saveOnBlur (default)

saveOnBlur={false} + showActions

Empty means cancel

required defaults to true, and it is not validation — there is no error message, no red border, nothing to dismiss. An empty draft is simply read as “never mind”: onSave is not called and the old value returns. For a task title or a customer name that is exactly right; an empty row in a list is worse than an unchanged one.

Set required={false} where clearing the field is a real edit — a nickname, an optional note. Then onSave('') fires and the read-only state falls back to the placeholder in text-muted-foreground.

required (default) — clear it, press Enter, it comes back

required={false} — clear it and the placeholder stays

Keyboard

KeySingle linemultiline
EnterSave and leave edit modeInsert a newline
Cmd / Ctrl + EnterSave (same as Enter)Save and leave edit mode
EscapeCancel — restore the value, leave edit modeCancel — restore the value, leave edit mode
TabMoves focus out, which blurs the field — saves when saveOnBlur is onSame, and the textarea does not trap Tab
Space / Enter on the closed fieldNative <button> activation — enters edit modeNative <button> activation — enters edit mode

Both Enter and Escape call preventDefault(), so an inline edit inside a <form> will not submit it on Enter. Escape resets the draft back to value before leaving edit mode, so nothing can be committed on the way out.

What preventDefault() does not do is keep Escape to itself. It is not stopPropagation(), and the dismissable layers — Dialog, Sheet, Popover, DropdownMenu — listen for Escape on the document in the capture phase, so they see the key before the input's handler ever runs. An inline edit inside a dialog therefore cancels the edit and closes the dialog on a single press. If the field needs the first Escape for itself, take it on the layer: hold your own “is editing” flag and call event.preventDefault() inside <DialogContent onEscapeKeyDown={…}>.

Styling the two states

There are two class props and the split matters. className is applied to both the read-only button and the input — that is where typography belongs, so the text does not jump a size at the moment of the swap. displayClassName is appended after it on the read-only state only, for anything that should not follow the text into the input.

The read-only state is a full-width <button> with a negative -mx-1 and matching padding, so its hover:bg-muted hit area reads as a slightly wider band while the text itself stays optically aligned with everything above and below it. Note that there is no rest-prop spread: InlineEditProps is a closed interface, so id, name, data-* and native handlers do not pass through.

className on both states

displayClassName on the read-only state only

Where it earns its keep

A table of editable cells is the case this component was built for. Every cell reads as text, hover shows the affordance, and only the cell being edited is an input — so the row still scans as data. Give each field its own ariaLabel naming the record, or a screen-reader user hears six buttons all called the same thing. Optional columns get required={false}.

SKUNameNote
SKU-1180
SKU-1204
SKU-1291

Props

PropTypeDefaultDescription
valuerequiredstringThe current value, owned by your data layer. InlineEdit never becomes the source of truth — it holds a draft while editing and hands the trimmed result to onSave.
onSaverequired(value: string) => void | Promise<void>Called with the TRIMMED draft, and only when it actually differs from the trimmed value. Return a promise to hold edit mode open until the save resolves.
placeholderstring'Nhấn để nhập…'Shown in the read-only state when value is empty (in muted-foreground) and as the input placeholder while editing. Vietnamese by default — pass an English string.
multilinebooleanfalseRenders a 3-row <textarea> instead of an <input>, and changes what Enter does: it inserts a newline, and Cmd/Ctrl+Enter saves. The read-only state switches from truncate to whitespace-pre-wrap so existing line breaks show.
maxLengthnumberPassed straight to the input or textarea as the native maxLength attribute. It caps typing; it does not validate an incoming value that is already longer.
disabledbooleanfalseDisables the read-only button, so edit mode cannot be entered (pointer-events-none, 60% opacity). It does not affect a session that is already open — the input is disabled only while a save is in flight.
requiredbooleantrueWhen true, an empty draft is treated as a cancel: onSave is not called and the previous value is restored. Set to false when clearing the field is a real edit.
saveOnBlurbooleantrueLeaving the field commits, the way Linear behaves. Turn it off for a form-like explicit commit — and then turn showActions on, or the only exits left are Enter and Escape.
showActionsbooleanfalseShows a ✓ / ✕ pair of ghost icon-sm buttons beside the input. They fire on mousedown rather than click, deliberately, so they run before the input blurs.
ariaLabelstring'Sửa tại chỗ'Accessible name for both the read-only button and the input. Vietnamese by default; write it as an action for the specific field — "Edit task title", not "Edit".
onError(error: unknown) => voidCalled when onSave rejects. Without it the component falls back to console.error, which no user will ever see — wire this to a toast.
classNamestringApplied to BOTH states — the read-only button and the input. This is where typography goes, so the text does not change size at the moment of the swap.
displayClassNamestringApplied to the read-only state only, after className. For anything that should not follow the text into the input.

Accessibility

  • The read-only state is a real <button type="button">, not a div with a click handler — it is in the tab order, responds to Enter and Space, and the explicit type stops it submitting a surrounding form.
  • ariaLabel is the accessible name of both states, so the name does not change under the user as the element swaps. It defaults to the Vietnamese "Sửa tại chỗ"; in an English UI that is a bug waiting in the invisible layer. Name the field, not the action alone — "Edit task title".
  • Entering edit mode focuses the input and places the caret at the end of the text (setSelectionRange(length, length)) rather than selecting all of it. Appending is the common intent, and a select-all means one stray keystroke wipes the value.
  • The ✓ / ✕ buttons are icon-only, with their names in an sr-only span and the icons aria-hidden. Those two strings are hard-coded Vietnamese (“Lưu” / “Hủy”) with no prop to reach them — if your app must announce them in English, keep showActions off and rely on the keyboard, which needs no label.
  • Nothing about the state change is announced. There is no live region here: a save that succeeds is silent, and a save that fails is silent too. Pair onError with the package toast, which does announce, and consider a success toast for edits with real consequences.
  • Focus comes from the shared ring — focus:ring-2 focus:ring-ring/50 plus focus:border-primary-ink on the input, and the button's own focus-visible outline on the closed state. The border uses the -ink role because it sits on the page background and owes the 3:1 non-text threshold, which the decorative border tier does not meet.
  • Hover is the only affordance in the resting state (hover:bg-muted), and hover does not exist on touch or for keyboard users. Where discoverability matters, put a visible label or a pencil icon beside the field rather than relying on people finding the click target.
  • disabled gates the read-only button only. A field that becomes disabled while someone is mid-edit stays editable until they commit or cancel — if that matters, unmount the component instead.