Skip to content
txbrendan/ui

Inline Edit

beta

A value people change where it sits. The value is the button, and the field opens in its exact box.

Preview

size

empty

saving

fails

disabled

Team, billed yearly
Owner
Due
Notes

Install

  1. 1. Add the registry to components.json, once per app. Without it, the shadcn CLI looks up @txbrendan in a public list that txbrendan/ui does not control.

    npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"
  2. 2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.

    npx shadcn@latest add @txbrendan/theme
  3. 3. Add the component.

    npx shadcn@latest add @txbrendan/inline-edit

Use it for

  • One short value on a page or a row that people change now and then: a name, a title, a place, a number.
  • Pass label as a short noun, like "location". It names the value "Edit location" and the empty state "Add location".
  • Save in onSave. Return the promise from your request, and the change shows at once, says Saving…, and comes back if it fails.
  • When you control value, set it to the new text before your promise resolves.

Avoid it for

  • Text that runs to several lines, like a description. Use a Textarea in a form or a dialog.
  • Values that must change together, like a start and an end date. Edit them in one form with one Save.
  • A form people fill in once from top to bottom. Use Input with a label.
  • Adding a pencil or an Edit button beside the value. The value is already the button.

Props

  • label

    What the value is, as a short noun. Required. It names the value and the empty state.

    string

  • value

    The saved value. Controlled. Set it to the new text before your onSave promise resolves.

    string

  • defaultValue

    The saved value, when you do not control value.

    string · default ""

  • onSave

    Called with the trimmed new text when it changed. Enter saves, and so does clicking away. Escape cancels. A promise shows Saving… until it settles. A rejection or a throw puts the old value back and shows errorText. The newest save wins.

    (value: string) => void | Promise<unknown>

  • size

    Text size: 11, 13, or 16px. The box is as tall as a small control, so density scales it.

    "sm" | "md" | "lg" · default "md"

  • disabled

    Shows the value as plain text. It is not a button and cannot be edited.

    boolean · default false

  • placeholder

    Shown in the empty field while editing.

    string

  • editText

    Words before the value in its name for screen readers. Set it to translate.

    string · default "Edit {label}"

  • addText

    Shown with a plus when the value is empty. Set it to translate.

    string · default "Add {label}"

  • savingText

    Shown after the value while its save runs.

    string · default "Saving…"

  • errorText

    Shown under the value when a save fails.

    string · default "Could not save. Try again."

  • className

    Extra classes on the outer span. Later classes win, so className='text-base' replaces the size.

    string

Accessibility

  • The value is a native <button> named "Edit {label}: {value}", so screen readers hear what it is and what it holds. An empty value is named "Add {label}".
  • Enter, Space, or a click opens a text field with the value, focused. A short value is selected, so typing replaces it.
  • Enter saves and Escape cancels. Both put focus back on the value. Clicking away saves and leaves focus where you clicked.
  • Escape stops at the field, so a dialog or popover around it stays open.
  • While a save runs, the value button is described by the Saving… text. A failed save shows its message with role="alert" and describes the button.
  • The field takes the value's exact box, so nothing on the page moves when it opens or closes.
  • On touch screens the text is 16px, so iOS Safari does not zoom in when the field opens.
  • A disabled value is plain text with no button, so it does not look like something to press.

Examples

Save to your server

<InlineEdit
  label="location"
  defaultValue="Berlin"
  onSave={(location) => fetch("/api/profile", { method: "PATCH", body: JSON.stringify({ location }) }).then((r) => {
    if (!r.ok) throw new Error("Save failed");
  })}
/>

Controlled

const [title, setTitle] = useState("Q3 planning");

<InlineEdit
  label="title"
  size="lg"
  value={title}
  onSave={async (next) => {
    await saveTitle(next);
    setTitle(next);
  }}
/>

Empty, in a table cell (needs @txbrendan/table)

<TableCell>
  <InlineEdit label="owner" placeholder="Name or email" onSave={setOwner} />
</TableCell>