Inline Edit Field
A click-to-edit inline field — the display and edit states swap seamlessly with a layout morph, Enter/blur submits and Esc cancels, with multiline support, controlled and uncontrolled modes, and no accidental submit mid-IME-composition.
Bring this into your project
Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.
npx webberui@latest add inline-edit-field.enInline Edit Field is a React inline edit (click-to-edit text) component built with Tailwind CSS and Motion, with lucide-react icons. It looks like plain text until you click it, then turns into an input in the same spot; single-line mode uses an input and multiline mode uses a textarea.
Playground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<InlineEditField/>
The complete example includes the preview layout and data. Install the component first, then paste this file into your project.
When to use
- Titles of projects, documents or kanban cards that people rename right on the page.
- Single fields in an admin list or settings page, such as team name, display name or notes.
- A bio on a profile page, using
multilinefor longer text. - Not a good fit: forms where several fields are filled in and validated together; a regular form with a submit button is clearer there.
Installation
npx webberui@latest add https://webberui.com/r/inline-edit-field.en.jsonOr, once registries are configured in components.json, install it as @webberui/inline-edit-field.
Usage
import { InlineEditField } from "@/components/ui/inline-edit-field";
<InlineEditField
value={title}
onValueChange={setTitle}
placeholder="Untitled event"
aria-label="Event title"
/>When value is omitted, the component manages the state internally (uncontrolled mode) and defaultValue supplies the initial value; passing value together with onValueChange puts it in controlled mode. onValueChange only fires on "submit" (Enter, blur, or pressing ✓), never on every keystroke.
Multiline mode:
<InlineEditField
multiline
maxRows={5}
variant="outline"
defaultValue="Add a short intro⋯"
/>Examples
Save to an API on submit and roll back on failure
onValueChange only fires on submit, so you can call your API directly from it. In controlled mode, update the UI first and restore the old value if the request fails.
"use client";
import { useState } from "react";
import { InlineEditField } from "@/components/ui/inline-edit-field";
export function ProjectTitle({ id, initial }: { id: string; initial: string }) {
const [title, setTitle] = useState(initial);
async function save(next: string) {
const prev = title;
setTitle(next);
const res = await fetch(`/api/projects/${id}`, {
method: "PATCH",
body: JSON.stringify({ title: next }),
});
if (!res.ok) setTitle(prev);
}
return (
<InlineEditField
value={title}
onValueChange={save}
placeholder="Untitled project"
aria-label="Project name"
/>
);
}Reject empty values
The component does no validation itself. In controlled mode the display only changes when you update value, so you can filter invalid input inside onValueChange.
<InlineEditField
value={name}
onValueChange={(next) => {
const trimmed = next.trim();
if (trimmed) setName(trimmed);
}}
aria-label="Team name"
/>Use it as a page heading
Put the font size on the outer className and both the display text and the input inherit it. This example hides the action buttons and treats blur as cancel, so only Enter saves.
<InlineEditField
defaultValue="Q3 product roadmap"
showActions={false}
submitOnBlur={false}
aria-label="Document title"
className="text-2xl font-semibold"
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Controlled value; when omitted, the component manages it internally |
defaultValue | string | "" | Initial value in uncontrolled mode |
onValueChange | (value: string) => void | — | Fires on submit (Enter / blur / pressing ✓) with the latest value |
onEditingChange | (editing: boolean) => void | — | Fires when the editing state toggles |
placeholder | string | "Click Edit..." | Text shown when the value is empty, also used as the input hint |
multiline | boolean | false | Use a multiline textarea (Enter inserts a newline, ⌘/Ctrl+Enter submits) |
maxRows | number | 6 | Maximum rows in multiline mode; beyond that the input scrolls |
disabled | boolean | false | Disabled: read-only display, editing cannot be entered |
submitOnBlur | boolean | true | Whether blurring submits; false treats blur as a cancel |
selectOnEdit | boolean | true | Whether to select all the existing text when entering edit mode |
showActions | boolean | true | Whether to show the ✓ / ✕ action buttons while editing |
variant | "ghost" | "outline" | "ghost" | Display style: ghost (background only on hover/focus) or outline (a permanent border) |
aria-label | string | — | Accessible label; falls back to placeholder when omitted |
className | string | — | Appended to the outermost container |
inputClassName | string | — | Appended to the input element (input / textarea) |
How it works
- Click to edit: the display state is a button occupying the same position and font size as the text; clicking it (or Tab-focusing it and pressing Enter / Space) swaps it for an input, and a pencil icon appears on the right on hover to signal that it is editable
- Seamless morph: the display button and the edit area cross-fade through
AnimatePresence mode="popLayout", while the outer container carrieslayoutso a spring smoothly makes up the size difference and the text never jumps out of position; the edit state's border, background, and focus ring fade in with a CSS transition - Submit and cancel: single-line presses Enter to submit, Esc to cancel, and blur submits by default (
submitOnBlur); multiline uses Enter for a newline, ⌘/Ctrl+Enter to submit, and Esc to cancel.onValueChangedoes not fire when the value is unchanged - Action buttons do not jump the gun: ✓ / ✕ block the default behaviour with
onMouseDownto keep the input focused, avoiding a race where the click first triggers a blur submit - Multiline auto-grow:
multilinegrows with the content and scrolls inside the box pastmaxRows(using the textarea's own auto-resize) - IME safe: pressing Enter mid-composition in Chinese and other input methods does not accidentally submit (it checks both
isComposingandkeyCode !== 229, covering Safari's habit of firing keydown only aftercompositionend)
Accessibility
- When the user has "reduce motion" enabled at the system level, all morph animations are disabled and display/edit switch instantly (the first render matches SSR; it is only disabled after mount)
- The display state is a native
<button>with anaria-labeldescribing the value, takes Tab focus in order, and shows a ring onfocus-visible - The input carries an
aria-label(falling back toplaceholderwhen omitted) - When editing ends via the keyboard or the action buttons, focus returns to the display button; when it ends because of a blur, focus is not stolen back, so it does not interfere with whatever the user actually clicked
- ✓ / ✕ are native
<button>s, each with a "confirm" and "cancel"aria-label
FAQ
Does it work with the Next.js App Router?
Yes. The file starts with "use client", so import it from a Server Component page as usual. If you save through a Server Action, call it from onValueChange.
When does onValueChange fire?
Only on submit: Enter in single-line mode, Cmd/Ctrl+Enter in multiline mode, the check button, or blur when submitOnBlur is on. It does not fire if the value is unchanged, and Esc or the cancel button discards the draft.
Will typing with an IME (Chinese, Japanese, Korean) submit by accident?
No. Pressing Enter while composing does not submit; the component checks both isComposing and keyCode 229, which covers Safari's different event order.
How do I change the look?
variant is either ghost (background only on hover or while editing) or outline (always-visible border). Add Tailwind classes to the outer container with className and to the input itself with inputClassName.
What are the dependencies?
motion, lucide-react, clsx and tailwind-merge. If you prefer not to use the CLI, copy the source from the Manual tab in Installation.