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…

"use client"

import * as React from "react"
import { Loader2, Pencil } from "lucide-react"
import { cn } from "@/lib/utils"

/** Hoisted via React 19 <style href precedence> — no Tailwind config edits. */
const KEYFRAMES = `@keyframes zie-flash{from{background-color:color-mix(in oklab,var(--primary) 22%,transparent)}to{background-color:transparent}}`

export interface InlineEditProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Controlled text — the component never owns it, it only proposes a next one. */
  value: string
  /** Return a Promise to get the pending state; reject to roll back and surface the reason. */
  onSave: (next: string) => void | Promise<void>

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/inline-edit.json

Prompt

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 div with onClick would silently exclude keyboard users.
  • Commit boundaries — Enter, blur and Escape are three distinct exits, and saveOn decides 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 onSave locks 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.

On This Page