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
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | The current text. Controlled. | |
| onSave | (value: string) => void | Called with the trimmed text when it changed. | |
| placeholder | string | "Empty" | Shown while the value is empty. |
| label | string | Accessible 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