Skip to contentVibraUI
Inputs & filters

Inline edit

Text that turns into a field in place, saving on Enter and putting itself back on Escape.

Enter saves, Escape cancels and puts focus back on the display button, and leaving the field saves too. An unchanged value never reaches onSave. A validation message keeps the editor open, marks the field invalid, is tied to it with aria-describedby, and is said aloud as it lands (role="alert"): after Enter the focus stays in the field, where a description is not read again. A rejected promise from onSave is surfaced the same way. The aria-label names the field: the display button reads as Edit plus that name, or plus the text itself when there is no label.

Install

npx shadcn@latest add @vibra/inline-edit

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
valuestring—The text as it stands; the caller applies what onSave reports.
onSave(value: string) => void | Promise<void>—Called only when the text actually changed; a promise shows a spinner until it settles.
placeholderstring"Empty"Stands in for an empty value, in the display and in the field.
multilinebooleanfalseEdits in a textarea, where Enter saves and Shift+Enter starts a new line.
validate(value: string) => string | null—Returns a message to block the save, or null to allow it.
renderDisplay(value: string) => React.ReactNode—Renders the resting state, e.g. as a badge or a formatted amount.
size"sm" | "default""default"sm drops the field and the display row to h-7.
disabledbooleanfalseStops the display button from opening the editor.

Dependencies

Source

components/ui/inline-edit.tsx
"use client"

import * as React from "react"
import { PencilIcon } from "lucide-react"

import { cn } from "@/lib/utils"
import { Input } from "@/components/ui/input"
import { Spinner } from "@/components/ui/spinner"
import { Textarea } from "@/components/ui/textarea"

export type InlineEditProps = React.ComponentProps<"div"> & {
  value: string
  /** Called only when the text actually changed; may return a promise, which shows a spinner until it settles. */
  onSave: (value: string) => void | Promise<void>
  placeholder?: string
  /** Edits in a textarea, where Enter saves and Shift+Enter starts a new line. */
  multiline?: boolean
  /** Returns a message to block the save, or null to allow it. */
  validate?: (value: string) => string | null
  renderDisplay?: (value: string) => React.ReactNode
  size?: "sm" | "default"
  disabled?: boolean
}

/** Text that turns into a field in place: click to edit, Enter to save, Escape to put it back. */
function InlineEdit({
  className,
  value,
  onSave,
  placeholder = "Empty",
  multiline = false,
  validate,
  renderDisplay,
  size = "default",
  disabled = false,
  "aria-label": ariaLabel,
  ...props
}: InlineEditProps) {
  const errorId = React.useId()
  const displayRef = React.useRef<HTMLButtonElement>(null)
  const restoreFocus = React.useRef(false)

  const [editing, setEditing] = React.useState(false)
  const [draft, setDraft] = React.useState(value)
  const [error, setError] = React.useState<string | null>(null)
  const [saving, setSaving] = React.useState(false)

  React.useEffect(() => {
    if (!editing && restoreFocus.current) {
      restoreFocus.current = false
      displayRef.current?.focus()
    }
  }, [editing])

  function open() {
    if (disabled) return
    setDraft(value)
    setError(null)
    setEditing(true)
  }

  function cancel() {
    restoreFocus.current = true
    setError(null)
    setEditing(false)
  }

  async function commit() {
    if (saving) return
    // Nothing changed, so there is nothing to save and nothing to validate.
    if (draft === value) {
      setEditing(false)
      return
    }

    const message = validate?.(draft) ?? null
    if (message) {
      setError(message)
      return
    }

    setSaving(true)
    try {
      await onSave(draft)
      setError(null)
      setEditing(false)
    } catch (cause) {
      setError(cause instanceof Error ? cause.message : "Could not save")
    } finally {
      setSaving(false)
    }
  }

  function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) {
    if (event.key === "Escape") {
      event.preventDefault()
      cancel()
      return
    }
    if (event.key === "Enter" && !(multiline && event.shiftKey)) {
      event.preventDefault()
      restoreFocus.current = true
      void commit()
    }
  }

  const controlProps = {
    autoFocus: true,
    value: draft,
    disabled: saving,
    "aria-label": ariaLabel,
    "aria-invalid": error ? true : undefined,
    "aria-describedby": error ? errorId : undefined,
    placeholder,
    onChange: (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
      setDraft(event.target.value)
      if (error) setError(null)
    },
    onFocus: (event: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) =>
      event.currentTarget.select(),
    onKeyDown: handleKeyDown,
    // Leaving the field saves, but must not yank focus back to the display
    // button afterwards — only Enter and Escape do that. The exception is the
    // blur a browser fires when a save disables the focused field: that one is
    // the save's own doing, and the focus it drops is the one Enter promised
    // to give back.
    onBlur: () => {
      if (!saving) restoreFocus.current = false
      void commit()
    },
  }

  return (
    <div
      data-slot="inline-edit"
      data-size={size}
      data-editing={editing || undefined}
      className={cn("flex w-full flex-col gap-1", className)}
      {...props}
    >
      {editing ? (
        <div className="relative flex items-center">
          {multiline ? (
            <Textarea {...controlProps} rows={3} className="pe-8 text-sm" />
          ) : (
            <Input {...controlProps} className={cn("pe-8", size === "sm" ? "h-7" : "h-8")} />
          )}
          {saving ? (
            <Spinner className="absolute end-2.5 size-3.5 text-muted-foreground" />
          ) : null}
        </div>
      ) : (
        <button
          ref={displayRef}
          type="button"
          data-slot="inline-edit-display"
          // Names what the button edits — the field's own label when it has one,
          // the text it holds otherwise.
          aria-label={`Edit ${ariaLabel || value || placeholder}`}
          disabled={disabled}
          onClick={open}
          // The padding is the hover target, so it bleeds outside the row: the
          // text itself lines up with whatever sits above and below it.
          className={cn(
            "group/inline-edit -mx-2 flex w-[calc(100%+1rem)] items-center gap-2 rounded-md px-2 text-start text-sm transition-colors hover:bg-accent focus-ring disabled:pointer-events-none disabled:opacity-50",
            size === "sm" ? "min-h-7 py-1" : "min-h-8 py-1.5"
          )}
        >
          <span
            className={cn(
              "min-w-0 flex-1",
              multiline ? "whitespace-pre-wrap" : "truncate",
              !value && "text-muted-foreground"
            )}
          >
            {renderDisplay ? renderDisplay(value) : value || placeholder}
          </span>
          <PencilIcon
            aria-hidden="true"
            className="size-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover/inline-edit:opacity-100 group-focus-visible/inline-edit:opacity-100"
          />
        </button>
      )}

      {/* Said aloud as it lands, as well as tied to the field: after Enter the
          focus stays in the field, where a description is not read again. */}
      {error ? (
        <p id={errorId} role="alert" data-slot="inline-edit-error" className="text-xs text-danger">
          {error}
        </p>
      ) : null}
    </div>
  )
}

export { InlineEdit }