Text that becomes an input in place, saves optimistically, then draws a tick.
Text inputsmotion
01Preview
NW-482
Press Enter to edit
- Project
- Press Enter to edit
- Slug
- Press Enter to edit
- Owner
- Press Enter to edit
02Install
Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:
npm install motion03Usage
import { InlineEdit } from "@/components/ui/inline-edit";
<InlineEdit
label="Project"
defaultValue="Northwind web"
validate={(v) => (v ? undefined : "Enter a project name")}
onSave={(name) => api.projects.rename(id, name)}
/>
// Set type on className; both states inherit it.
<InlineEdit label="Issue title" defaultValue={title} className="text-[16px] font-medium" onSave={saveTitle} />04Source
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useId, useRef, useState } from "react";
import { cn } from "@/lib/cn";
import { Alert, Loader, Pencil } from "@/lib/icons";
import { ease, spring } from "@/lib/motion";
import { useControllableState } from "@/lib/use-controllable-state";
type Mode = "idle" | "editing" | "saving" | "saved";
type SaveResult = void | string | undefined | null;
export type InlineEditProps = Omit<React.ComponentProps<"div">, "defaultValue" | "onChange" | "children"> & {
/** The saved text, when controlled. Only changes after a save succeeds. */
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
/** Names the field for assistive tech, e.g. "Project name". */
label: string;
/** Persist the new text. Return or resolve a string, or throw, to stay in edit mode with that message. */
onSave?: (next: string) => SaveResult | Promise<SaveResult>;
/** Runs before saving. Return a message to keep editing. */
validate?: (next: string) => string | undefined | null;
/** Shown in the quiet style when the value is empty. */
placeholder?: string;
/** Trims whitespace before validating and saving. */
trim?: boolean;
maxLength?: number;
disabled?: boolean;
};
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
export function InlineEdit({
value: valueProp,
defaultValue = "",
onValueChange,
label,
onSave,
validate,
placeholder = "Empty",
trim = true,
maxLength,
disabled,
className,
...rest
}: InlineEditProps) {
const [value, setValue] = useControllableState({ value: valueProp, defaultValue, onChange: onValueChange });
const [mode, setMode] = useState<Mode>("idle");
const [draft, setDraft] = useState(value);
const [pending, setPending] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [slow, setSlow] = useState(false);
const reduce = useReducedMotion();
const input = useRef<HTMLInputElement>(null);
const display = useRef<HTMLButtonElement>(null);
// Where focus should land after leaving edit mode: back on the text for Enter and Escape, nowhere for blur.
const refocus = useRef(false);
// True only while the input is live, so a blur that follows Enter, Escape or unmounting can't save twice.
const live = useRef(false);
const focusOnEdit = useRef(true);
const savedTimer = useRef<number>(undefined);
const errorId = useId();
const hintId = useId();
useEffect(() => () => window.clearTimeout(savedTimer.current), []);
useEffect(() => {
if (mode === "editing") {
const el = input.current;
if (el && focusOnEdit.current && document.activeElement !== el) {
el.focus();
el.select();
// Selecting scrolls a long value to its end; keep the start where the text just was.
el.scrollLeft = 0;
}
focusOnEdit.current = true;
} else if (refocus.current) {
refocus.current = false;
display.current?.focus();
}
}, [mode]);
const startEditing = () => {
if (disabled || mode === "saving") return;
window.clearTimeout(savedTimer.current);
live.current = true;
setDraft(value);
setError(null);
setMode("editing");
};
const cancel = () => {
live.current = false;
refocus.current = true;
setError(null);
setMode("idle");
};
const commit = async (raw: string, fromKeyboard: boolean) => {
if (!live.current) return;
const next = trim ? raw.trim() : raw;
if (next === value) {
live.current = false;
refocus.current = fromKeyboard;
setError(null);
setMode("idle");
return;
}
const invalid = validate?.(next);
if (invalid) {
// Stays open with the message. Focus is left where the person put it; Enter already has it here.
setError(invalid);
return;
}
// Optimistic: the new text shows at once, with a spinner only if the save is slow.
live.current = false;
refocus.current = fromKeyboard;
setPending(next);
setError(null);
setMode("saving");
const started = performance.now();
const slowTimer = window.setTimeout(() => setSlow(true), 150);
let failure: string | null = null;
try {
const result = await onSave?.(next);
if (typeof result === "string" && result) failure = result;
} catch (e) {
failure = e instanceof Error && e.message ? e.message : "Couldn't save. Try again.";
}
window.clearTimeout(slowTimer);
// A spinner that appeared stays at least 300ms, so it never reads as a flicker.
const elapsed = performance.now() - started;
if (elapsed > 150) await wait(Math.max(0, 450 - elapsed));
setSlow(false);
setPending(null);
if (failure) {
refocus.current = false;
// Reopen with the message; take focus back only if they were still here (Enter), never after they moved on.
focusOnEdit.current = fromKeyboard;
live.current = true;
setDraft(next);
setError(failure);
setMode("editing");
return;
}
setValue(next);
setMode("saved");
savedTimer.current = window.setTimeout(() => setMode((m) => (m === "saved" ? "idle" : m)), 1600);
};
const shown = pending ?? value;
const editing = mode === "editing";
// Both states share this box and inherit the root's type (set it with className): same font, padding,
// border width and line box, so the text never moves when it turns into an input.
const box = "w-full min-w-0 rounded-md border px-2 py-[5px] text-left [font:inherit] [letter-spacing:inherit] outline-none pointer-coarse:[font-size:max(16px,1em)]";
return (
<div
data-slot="inline-edit"
data-state={mode}
data-invalid={error ? "" : undefined}
data-disabled={disabled || undefined}
className={cn("group/inline relative -mx-2 flex min-w-0 flex-col", className)}
{...rest}
>
{editing ? (
<input
ref={input}
value={draft}
maxLength={maxLength}
aria-label={label}
aria-invalid={error ? true : undefined}
aria-describedby={error ? errorId : undefined}
enterKeyHint="done"
onChange={(e) => {
setDraft(e.target.value);
if (error) setError(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.nativeEvent.isComposing) {
e.preventDefault();
commit(e.currentTarget.value, true);
} else if (e.key === "Escape") {
// Handled here so a surrounding dialog doesn't close along with the edit.
e.preventDefault();
e.stopPropagation();
cancel();
}
}}
onBlur={(e) => commit(e.currentTarget.value, false)}
className={cn(
box,
"block bg-raised text-fg shadow-[var(--shadow)] ring-3 transition-[border-color,box-shadow] duration-150 ease-out",
error ? "border-danger/70 ring-danger/15" : "border-line-2 ring-fg/8",
)}
/>
) : (
<button
ref={display}
type="button"
disabled={disabled}
aria-label={`${label}: ${shown || placeholder}`}
aria-describedby={hintId}
aria-busy={mode === "saving" || undefined}
onClick={startEditing}
onKeyDown={(e) => {
if (e.key === "F2") {
e.preventDefault();
startEditing();
}
}}
className={cn(
box,
"group/display relative flex items-center gap-1.5 border-transparent pr-8",
"transition-[background-color,color] duration-150 ease-out hover:bg-hover",
"focus-visible:bg-hover focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3 focus-visible:outline-solid",
"disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent",
mode === "saving" && "cursor-progress",
)}
>
<span className={cn("min-w-0 truncate", shown ? "text-fg" : "text-fg-4")}>{shown || placeholder}</span>
{/* Spinner and tick share one slot right after the text, so the result lands where the eye already is. */}
<span aria-hidden className="relative grid size-3.5 shrink-0 place-items-center text-fg-3">
<AnimatePresence initial={false}>
{mode === "saving" && slow && (
<motion.span
key="spin"
className="absolute inset-0 grid place-items-center"
initial={{ opacity: 0, scale: reduce ? 1 : 0.6 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: reduce ? 1 : 0.6, transition: { duration: 0.1 } }}
transition={reduce ? { duration: 0.12 } : spring.pop}
>
<Loader size={14} className="animate-spin" />
</motion.span>
)}
{mode === "saved" && (
<motion.span
key="tick"
className="absolute inset-0 grid place-items-center text-success"
initial={{ opacity: 0, scale: reduce ? 1 : 0.6 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, transition: { duration: 0.2 } }}
transition={reduce ? { duration: 0.12 } : spring.pop}
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<motion.path
d="M3.5 8.5 6.5 11.5 12.5 4.5"
initial={reduce ? false : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.32, ease: ease.out, delay: 0.04 }}
/>
</svg>
</motion.span>
)}
</AnimatePresence>
</span>
{/* The pencil only surfaces on hover or keyboard focus; on touch it stays faintly visible. */}
<span
aria-hidden
className={cn(
"absolute right-2 top-1/2 -translate-y-1/2 text-fg-4 opacity-0 transition-[opacity,translate] duration-150 ease-out",
"-translate-x-0.5 group-hover/display:translate-x-0 group-hover/display:opacity-100 group-focus-visible/display:translate-x-0 group-focus-visible/display:opacity-100",
"pointer-coarse:translate-x-0 pointer-coarse:opacity-60",
(mode === "saving" || disabled) && "hidden",
)}
>
<Pencil size={14} />
</span>
</button>
)}
<div className="grid grid-rows-[0fr] transition-[grid-template-rows] duration-200 ease-out-quart data-[open]:grid-rows-[1fr] motion-reduce:transition-none" data-open={error ? "" : undefined}>
<div className="min-h-0 overflow-hidden">
<AnimatePresence initial={false}>
{error && (
<motion.p
key="error"
id={errorId}
className="flex items-start gap-1.5 px-2 pt-1.5 font-sans text-[12px] font-normal leading-4 tracking-normal text-danger"
initial={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, transition: { duration: 0.1 } }}
transition={{ duration: 0.2, ease: ease.out }}
>
<Alert size={14} className="mt-px size-3.5 shrink-0" />
<span className="min-w-0">{error}</span>
</motion.p>
)}
</AnimatePresence>
</div>
</div>
<span id={hintId} className="sr-only">
Press Enter to edit
</span>
<span role="status" aria-live="polite" className="sr-only">
{mode === "saved" ? `${label} saved` : error ?? ""}
</span>
</div>
);
}05Props
| Prop | Type | Default | Description |
|---|---|---|---|
| label* | string | — | Names the field for assistive tech: the button reads "Project: Northwind web", the input is labeled "Project". |
| value | string | — | The saved text, when controlled. Changes only after a save succeeds. |
| defaultValue | string | "" | The starting text, when uncontrolled. |
| onValueChange | (value: string) => void | — | Called with the new text once it has saved. |
| onSave | (next: string) => void | string | Promise<void | string> | — | Persist the change. Return or resolve a message, or throw, to reopen the field with it. |
| validate | (next: string) => string | undefined | — | Checked before saving. A message keeps the field open with the error under it. |
| placeholder | string | "Empty" | Shown in the faint color when there is no value. |
| trim | boolean | true | Trims whitespace before validating and saving. |
| maxLength | number | — | Hard limit on the input. |
| disabled | boolean | false | Shows the text without the edit affordance. |
| className | string | — | Classes for the root. Set the type here (size, weight, family) and the display and the input inherit it identically. |
06Notes
Behavior
- Display and input share one box (padding, border width, line box, inherited font), so the text doesn't move by a pixel when it turns into a field. The whole text is selected on entry.
- Enter saves and Escape cancels, both returning focus to the text; blur saves too, but never pulls focus back. Escape is stopped at the field, so a dialog around it stays open.
- Saving is optimistic: the new text shows at once. A spinner appears only if the save takes longer than 150ms and then stays for at least 300ms; a failure reopens the field with your draft and the message.
- An unchanged value just closes, with no save and no tick. A failed check keeps the field open with the error under it.
Motion
- The tick pops in on the pop spring and draws its stroke in 320ms on the expo ease-out, right after the text where the eye already is; it fades after 1.6s.
- The error drops 4px into a row that opens over 200ms, so the rows below ease down instead of jumping.
- The pencil slides 2px in on hover or keyboard focus. Reduced motion keeps short fades and drops the pop, the stroke draw and the slide.
Accessibility
- At rest it's a button named with the label and value, described as "Press Enter to edit"; F2 also opens it.
- While editing, the input carries aria-invalid and aria-describedby pointing at the error.
- A polite live region announces "Project saved" or the error message, since the change happens out of view of a screen reader's cursor.
- On touch screens the text is set at 16px or more in both states, so iOS doesn't zoom when the field opens, and the pencil stays faintly visible.