Skip to content
goodtabs UI
BrowseBrowse components

Inline edit

Text that becomes an input on click. Enter saves, Escape cancels, blur saves.

npx shadcn@latest add https://ui.goodtabs.co/r/inline-edit.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/inline-edit

A project title. Hover shows the pencil, click or Enter edits.

As a heading

The same control rendered as an h2; the input takes the heading's size.

Empty, with a placeholder

An empty value shows the placeholder in the muted tone.

Usage

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

const [title, setTitle] = useState("Website redesign")

<InlineEdit as="h2" value={title} onSave={setTitle} label="Project title" className="text-2xl font-medium tracking-tight" />

Props

PropTypeDefaultDescription
valuestringThe current text. Controlled.
onSave(value: string) => voidCalled with the trimmed text when it changed.
placeholderstring"Empty"Shown while the value is empty.
labelstringAccessible name for the field, for example "Project title".
as"span" | "p" | "div" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6""span"The element around the text.

Source

inline-edit/inline-edit.tsx
"use client";

import * as React from "react";
import { PencilIcon } from "lucide-react";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";

type DisplayElement = "span" | "p" | "div" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6";

type InlineEditProps = Omit<React.HTMLAttributes<HTMLElement>, "onChange" | "children"> & {
  /** The current text. Controlled: store what onSave gives you and pass it back. */
  value: string;
  /** Called with the trimmed text on Enter or blur, only when it changed. */
  onSave: (value: string) => void;
  /** Shown in the muted tone while the value is empty. */
  placeholder?: string;
  /** Accessible name for the field, for example "Project title". */
  label: string;
  /** The element around the text, so it can be a heading. Font size and weight come from className. */
  as?: DisplayElement;
};

/**
 * Text that turns into an input on click or Enter.
 * Enter saves, Escape cancels, blur saves. The input takes the font of the element it replaces.
 */
export function InlineEdit({
  value,
  onSave,
  placeholder = "Empty",
  label,
  as: Tag = "span",
  className,
  ...props
}: InlineEditProps) {
  const [editing, setEditing] = React.useState(false);
  const [draft, setDraft] = React.useState(value);
  const buttonRef = React.useRef<HTMLButtonElement>(null);
  const done = React.useRef(false);
  const restoreFocus = React.useRef(false);

  const start = () => {
    setDraft(value);
    done.current = false;
    setEditing(true);
  };

  const finish = (save: boolean) => {
    if (done.current) return;
    done.current = true;
    const next = draft.trim();
    if (save && next !== value) onSave(next);
    restoreFocus.current = true;
    setEditing(false);
  };

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

  return (
    <Tag
      data-slot="inline-edit"
      data-editing={editing || undefined}
      className={cn("inline-block max-w-full", className)}
      {...props}
    >
      {editing ? (
        <span className="-mx-1 inline-grid max-w-full align-top">
          <span
            aria-hidden="true"
            className="invisible col-start-1 row-start-1 border border-transparent px-1 whitespace-pre"
          >
            {draft || placeholder}
          </span>
          <Input
            autoFocus
            value={draft}
            aria-label={label}
            placeholder={placeholder}
            onChange={(e) => setDraft(e.target.value)}
            onFocus={(e) => e.currentTarget.select()}
            onBlur={() => finish(true)}
            onKeyDown={(e) => {
              if (e.key === "Enter") {
                e.preventDefault();
                finish(true);
              } else if (e.key === "Escape") {
                e.preventDefault();
                e.stopPropagation();
                finish(false);
              }
            }}
            className="col-start-1 row-start-1 h-auto w-full min-w-16 rounded-sm px-1 py-0"
            style={{ fontSize: "inherit", lineHeight: "inherit" }}
          />
        </span>
      ) : (
        <button
          ref={buttonRef}
          type="button"
          onClick={start}
          className="group/inline-edit -mx-1 inline-flex max-w-[calc(100%+0.5rem)] items-center gap-1.5 rounded-sm border border-transparent px-1 text-left transition-colors duration-100 ease-out outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50"
        >
          <span className={cn("truncate", !value && "text-muted-foreground")}>{value || placeholder}</span>
          <PencilIcon
            aria-hidden="true"
            className="size-4 shrink-0 text-muted-foreground opacity-0 transition-opacity duration-100 ease-out group-hover/inline-edit:opacity-100 group-focus-visible/inline-edit:opacity-100"
          />
          <span className="sr-only">, edit {label}</span>
        </button>
      )}
    </Tag>
  );
}

For AI agents

Hand your agent this page, the markdown at /components/inline-edit/prompt.md, or the Copy AI prompt button above.

When to use

  • Renaming a project, document, column or label in place
  • Titles and short one-line fields that are edited rarely and read often
  • Anywhere a full form would be heavier than the change

When not to use

  • For multi-line text; use a textarea in a form
  • When the value needs validation or a save button; use labeled-field in a form
  • For values that change often, where a plain Input is clearer

Agent notes

  • It is controlled: keep value in state and set it in onSave. onSave is a function, so render it from a client component
  • onSave only fires when the trimmed text differs from value; Escape restores the old text
  • Put font size and weight on className; the display button and the input both inherit them, so the swap does not shift the layout
  • The display is a real button inside the as element, so a heading stays a heading for screen readers