Inputs
Inline Edit
Click-to-edit text that swaps a button for an input in place — pending state on async saves, rollback with the failure reason, and no width jump.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/inline-edit.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "InlineEdit" component (lucide-react
Loader2 + Pencil).
Contract
- Export a forwardRef component; the ref points at the wrapper <div> and props
extend HTMLAttributes<HTMLDivElement>.
- Controlled text: value (string) + onSave(next: string) => void | Promise<void>.
The component never mutates value — it proposes the next one and lets the
parent (or the server) decide.
- Options: placeholder (muted text when value is empty, default "Empty"),
saveOn = "blur" | "enter" | "both" (default "both"), maxLength, disabled,
validate?: (next: string) => string | null, className.
- validate returning a string blocks the save and keeps the editor open with
the typed text; returning null lets it through. When the blocked commit came
from a blur, focus is pulled back into the input: the editor stays mounted, so
letting focus land outside it would strand the row in edit mode with no exit
but clicking back in. Escape still cancels and restores the value.
Behavior
- Display state is a real <button type="button">, not a div: it is tabbable,
Enter/Space activates it, and its accessible name is "Edit: <value>".
On hover/focus it grows a dashed bottom border and reveals a pencil icon —
the affordance appears, but the row is never visually noisy at rest.
- Entering edit: swap in an <input>, focus it and select all, so typing
replaces and arrow keys refine.
- Exits: Enter commits (with saveOn="blur" it simply blurs, keeping blur as
the single commit path), Escape always cancels and restores value, blur
commits unless saveOn === "enter" (then it cancels). Keyboard exits return
focus to the display button; a blur exit does not steal focus back.
- Commit pipeline: trim → validate → skip if unchanged → call onSave.
If onSave returns a Promise, enter pending: the input is disabled, an inline
spinner appears, and a visually-hidden aria-live region announces "Saving".
- Resolve: leave edit mode and flash the row's background once
(color-mix on --primary, ~700ms, cleared in onAnimationEnd, never started
when prefers-reduced-motion matches).
- Reject: roll back to the display state showing the untouched value and
render the rejection message (Error.message, or a generic fallback) below in
destructive text with role="alert". Failure must never leave a half-applied
value on screen.
- A settling flag guards the commit path so a blur firing during an in-flight
save cannot start a second one; a mounted ref makes the post-await code a
no-op when the component unmounted mid-request.
- IME safety: ignore Enter/Escape while a composition is active.
Rendering & styling
- Display and editor share one CSS grid cell together with an invisible sizer
span holding the same text at the same padding and font — so switching modes
(and typing) never changes the component's width or shifts the layout
around it.
- Both modes reserve a trailing icon slot (pr-7), so the pencil and the
spinner never overlap the text.
- Tokens only: text uses the inherited foreground, placeholder and icons use
text-muted-foreground, the editor is border-input + focus-visible ring-ring,
errors use border-destructive and text-destructive, the success flash is
color-mix(in oklab, var(--primary) 22%, transparent). Dark mode is free.
- Ship the flash @keyframes through a React 19 hoisted
<style href precedence="medium"> tag; it is motion-reduce:[animation:none]
and the spinner is motion-reduce:animate-none — with animation off the
component still saves, rolls back and reports errors exactly the same.
- aria-invalid + aria-describedby link the editor to the error message; merge
the consumer className onto the wrapper via cn().
Customization levers
- Element: swap the display <button> content for a heading (<h1> styles) or a
table cell's text — keep it a button so keyboard users can still enter edit.
- Affordance strength: the dashed border + hover background are two
independent hints; drop either for a quieter row, or make the pencil always
visible for discoverability in dense tables.
- Save gestures: saveOn="enter" for destructive-ish fields where an accidental
click-away should discard; "blur" for spreadsheet-like grids.
- Optimistic mode: update your local state before awaiting onSave and revert
in the catch — the component already rolls its own view back, so only your
store needs the extra step.
- Field type: replace the input with a <textarea> (commit on Cmd/Ctrl+Enter),
a number input, or a select — the state machine and the sizer trick are
independent of the control.
- Feedback colors: the flash uses --primary; point it at a success token if
your palette has one, or remove it entirely for a silent save.Concepts
- Display is a button, not a div — the resting state is a real focusable control, so "click to edit" is also "Tab to it and press Enter"; a
divwithonClickwould silently exclude keyboard users. - Commit boundaries — Enter, blur and Escape are three distinct exits, and
saveOndecides which of the first two writes; making the choice explicit is what stops accidental saves in one app and lost edits in another. - A blocked save always leaves a way out — validation keeps the editor mounted, so the exit that failed has to hand focus back; a rejected blur that let focus drift away would leave the row editable forever with no keyboard route back to it.
- Pending is a state, not a spinner — an async
onSavelocks the input, shows the spinner and announces itself, so the row cannot be edited into an inconsistent value while the request is in flight. - Rollback with a reason — a rejected save restores the original text and renders the server's message; showing one without the other leaves users guessing whether their edit survived.
- No layout shift — an invisible sizer span sharing the grid cell keeps the display and editor the same width, so entering edit mode never nudges the surrounding layout.
- Unmount-safe async — the post-await code checks a mounted ref before touching state, so navigating away mid-save produces no warning and no zombie update.