# Inline Edit

> Click-to-edit text or textarea with Enter to save, Esc to cancel, async saving state and validation.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/inline-edit
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/inline-edit.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/inline-edit-demo.tsx"
"use client"

import * as React from "react"

import { InlineEdit } from "@/components/inline-edit"

function fakeRequest(ms = 900) {
  return new Promise<void>((resolve) => setTimeout(resolve, ms))
}

export default function InlineEditDemo() {
  const [saved, setSaved] = React.useState<string | null>(null)

  return (
    <div className="w-full max-w-md rounded-2xl border bg-card p-5 shadow-sm">
      <p className="mb-4 text-xs font-medium tracking-wide text-muted-foreground uppercase">Project settings</p>
      <div className="grid gap-5">
        <div className="grid min-w-0 gap-1">
          <span className="text-xs text-muted-foreground">Name</span>
          <InlineEdit
            label="Project name"
            defaultValue="Atelier website redesign"
            required
            maxLength={48}
            textClassName="text-lg font-semibold"
            validate={(v) => (v.toLowerCase() === "untitled" ? "Pick a more descriptive name." : null)}
            onSave={async () => {
              await fakeRequest()
            }}
            onValueChange={(v) => setSaved(`Renamed to "${v}"`)}
          />
        </div>
        <div className="grid min-w-0 gap-1">
          <span className="text-xs text-muted-foreground">Description</span>
          <InlineEdit
            label="Project description"
            multiline
            defaultValue="A refreshed marketing site with a new component library, dark mode and faster pages."
            placeholder="Add a short description…"
            onSave={async (v) => {
              await fakeRequest(700)
              if (v.toLowerCase().includes("fail")) throw new Error("Server rejected this change. Try again.")
            }}
            onValueChange={() => setSaved("Description updated")}
          />
        </div>
      </div>
      <p aria-live="polite" className="mt-4 min-h-4 text-xs text-muted-foreground">
        {saved ?? 'Tip: type "fail" in the description to see a save error.'}
      </p>
    </div>
  )
}
```

## Source

```tsx title="components/inline-edit.tsx"
"use client"

import * as React from "react"
import { Check, Loader2, Pencil, X } from "lucide-react"

import { cn } from "@/lib/utils"

export interface InlineEditProps
  extends Omit<React.ComponentProps<"div">, "onChange" | "defaultValue"> {
  value?: string
  defaultValue?: string
  /** Fires after a successful save */
  onValueChange?: (value: string) => void
  /** Async persistence hook. Throw / reject with an Error to show its message. */
  onSave?: (value: string) => Promise<void> | void
  /** Return an error message to block saving */
  validate?: (value: string) => string | null | undefined
  /** Accessible name, also used for the "Edit …" button */
  label: string
  placeholder?: string
  multiline?: boolean
  rows?: number
  maxLength?: number
  required?: boolean
  disabled?: boolean
  /** Extra classes for the displayed text and the editor, e.g. "text-xl font-semibold" */
  textClassName?: string
}

export function InlineEdit({
  value,
  defaultValue = "",
  onValueChange,
  onSave,
  validate,
  label,
  placeholder = "Click to add…",
  multiline = false,
  rows = 3,
  maxLength,
  required = false,
  disabled = false,
  textClassName,
  className,
  ...props
}: InlineEditProps) {
  const [inner, setInner] = React.useState(defaultValue)
  const current = value ?? inner
  const [editing, setEditing] = React.useState(false)
  const [draft, setDraft] = React.useState(current)
  const [saving, setSaving] = React.useState(false)
  const [error, setError] = React.useState<string | null>(null)
  const fieldRef = React.useRef<HTMLInputElement & HTMLTextAreaElement>(null)
  const displayRef = React.useRef<HTMLButtonElement>(null)
  const closingRef = React.useRef(false)
  const returnFocusRef = React.useRef(false)
  const errorId = React.useId()

  React.useEffect(() => {
    if (editing) {
      fieldRef.current?.focus()
      fieldRef.current?.select()
    } else if (returnFocusRef.current) {
      returnFocusRef.current = false
      displayRef.current?.focus()
    }
  }, [editing])

  function startEditing() {
    if (disabled) return
    closingRef.current = false
    setDraft(current)
    setError(null)
    setEditing(true)
  }

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

  async function save(refocus: boolean) {
    if (closingRef.current || saving) return
    const next = draft.trim()
    const problem = (required && !next ? `${label} can't be empty.` : null) ?? validate?.(next) ?? null
    if (problem) {
      setError(problem)
      fieldRef.current?.focus()
      return
    }
    if (next === current) {
      closingRef.current = true
      returnFocusRef.current = refocus
      setEditing(false)
      return
    }
    setError(null)
    setSaving(true)
    try {
      await onSave?.(next)
      if (value === undefined) setInner(next)
      onValueChange?.(next)
      closingRef.current = true
      returnFocusRef.current = refocus
      setEditing(false)
    } catch (err) {
      setError(err instanceof Error ? err.message : "Couldn't save. Try again.")
    } finally {
      setSaving(false)
    }
  }

  function onKeyDown(event: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) {
    if (event.key === "Escape") {
      event.preventDefault()
      cancel()
    } else if (event.key === "Enter") {
      if (multiline && event.shiftKey) return
      event.preventDefault()
      void save(true)
    }
  }

  const fieldClasses = cn(
    "w-full min-w-0 rounded-lg border bg-background px-2.5 py-1.5 text-sm shadow-xs outline-none transition-[box-shadow,border-color] focus-visible:border-ring focus-visible:ring-4 focus-visible:ring-ring/40 read-only:opacity-70",
    error && "border-destructive focus-visible:border-destructive focus-visible:ring-destructive/25",
    textClassName
  )

  if (!editing) {
    return (
      <div className={cn("w-full", className)} {...props}>
        <button
          ref={displayRef}
          type="button"
          disabled={disabled}
          aria-label={`Edit ${label}: ${current || placeholder}`}
          onClick={startEditing}
          className={cn(
            "group -mx-2.5 flex w-[calc(100%+1.25rem)] min-w-0 items-start gap-2 rounded-lg border border-transparent px-2.5 py-1.5 text-left text-sm transition-colors hover:border-border hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-60",
            textClassName
          )}
        >
          <span
            className={cn(
              "min-w-0 flex-1 break-words",
              multiline && "whitespace-pre-wrap",
              !current && "text-muted-foreground italic"
            )}
          >
            {current || placeholder}
          </span>
          <Pencil
            aria-hidden
            className="mt-0.5 size-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100 [@media(hover:none)]:opacity-70"
          />
        </button>
      </div>
    )
  }

  const sharedProps = {
    value: draft,
    maxLength,
    readOnly: saving,
    "aria-label": label,
    "aria-invalid": error ? true : undefined,
    "aria-describedby": errorId,
    placeholder,
    onChange: (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
      setDraft(event.target.value)
      if (error) setError(null)
    },
    onKeyDown,
    onBlur: () => void save(false),
  }

  return (
    <div className={cn("w-full", className)} {...props}>
      <div className="flex items-start gap-1.5">
        {multiline ? (
          <textarea
            {...sharedProps}
            ref={fieldRef}
            rows={rows}
            className={cn(fieldClasses, "resize-none")}
          />
        ) : (
          <input {...sharedProps} ref={fieldRef} type="text" className={fieldClasses} />
        )}
        <div className="flex shrink-0 items-center gap-1" onMouseDown={(event) => event.preventDefault()}>
          {saving ? (
            <span role="status" className="inline-flex size-8 items-center justify-center text-muted-foreground">
              <Loader2 className="size-4 animate-spin motion-reduce:animate-none" aria-hidden />
              <span className="sr-only">Saving…</span>
            </span>
          ) : (
            <>
              <button
                type="button"
                aria-label="Save"
                onClick={() => void save(true)}
                className="inline-flex size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/40"
              >
                <Check className="size-4" aria-hidden />
              </button>
              <button
                type="button"
                aria-label="Cancel"
                onClick={cancel}
                className="inline-flex size-8 items-center justify-center rounded-lg border bg-background transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/40"
              >
                <X className="size-4" aria-hidden />
              </button>
            </>
          )}
        </div>
      </div>
      <p
        id={errorId}
        role={error ? "alert" : undefined}
        className={cn("mt-1.5 text-xs", error ? "text-destructive" : "text-muted-foreground")}
      >
        {error ?? (multiline ? "Enter to save, Shift+Enter for a new line, Esc to cancel." : "Enter to save, Esc to cancel.")}
      </p>
    </div>
  )
}
```
