Click-to-edit text with a hover pencil affordance, a metric-identical input swap with zero layout shift, and a brief success flash on commit.
npx shadcn@latest add @paragon/inline-edit"use client";
import * as React from "react";
import { Pencil } from "lucide-react";
import { cn } from "@/lib/utils";
export interface InlineEditProps
extends Omit<React.ComponentProps<"div">, "defaultValue" | "onChange"> {
/** Controlled value. */
value?: string;
/** Initial value when uncontrolled. */
defaultValue?: string;
/** Called when an edit is committed (Enter or blur). */
onValueChange?: (value: string) => void;
placeholder?: string;
disabled?: boolean;
/** Accessible name for the field, applied to both view and edit modes. */
"aria-label"?: string;
}
/** Metrics shared by view and edit modes so swapping causes zero layout shift. */
const fieldMetrics =
"flex h-8 w-full items-center rounded-md border px-2 text-sm";
/**
* Click-to-edit text. View mode reveals a pencil affordance on hover;
* edit mode swaps to an input with identical metrics. Enter commits with
* a brief success flash, Escape reverts.
*/
export function InlineEdit({
value: valueProp,
defaultValue = "",
onValueChange,
placeholder = "Empty",
disabled = false,
className,
"aria-label": ariaLabel,
...props
}: InlineEditProps) {
const [uncontrolled, setUncontrolled] = React.useState(defaultValue);
const value = valueProp ?? uncontrolled;
const [editing, setEditing] = React.useState(false);
const [draft, setDraft] = React.useState("");
const [flash, setFlash] = React.useState(false);
const buttonRef = React.useRef<HTMLButtonElement>(null);
const flashTimer = React.useRef<ReturnType<typeof setTimeout>>(null);
const escaped = React.useRef(false);
React.useEffect(() => {
return () => {
if (flashTimer.current) clearTimeout(flashTimer.current);
};
}, []);
const beginEdit = () => {
setDraft(value);
escaped.current = false;
setEditing(true);
};
const commit = (refocus: boolean) => {
setEditing(false);
const next = draft.trim();
if (next !== "" && next !== value) {
if (valueProp === undefined) setUncontrolled(next);
onValueChange?.(next);
// Paint the flash instantly, then let it fade out via transition.
setFlash(true);
if (flashTimer.current) clearTimeout(flashTimer.current);
flashTimer.current = setTimeout(() => setFlash(false), 150);
}
if (refocus) {
// Return focus to the trigger after the input unmounts.
requestAnimationFrame(() => buttonRef.current?.focus());
}
};
const revert = () => {
escaped.current = true;
setEditing(false);
requestAnimationFrame(() => buttonRef.current?.focus());
};
return (
<div className={cn("w-full min-w-0", className)} {...props}>
{editing ? (
<input
type="text"
autoFocus
aria-label={ariaLabel}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onFocus={(e) => e.target.select()}
onKeyDown={(e) => {
if (e.nativeEvent.isComposing) return;
if (e.key === "Enter") {
e.preventDefault();
commit(true);
} else if (e.key === "Escape") {
e.preventDefault();
revert();
}
}}
onBlur={() => {
if (!escaped.current) commit(false);
}}
className={cn(
fieldMetrics,
"border-input bg-transparent outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 transition-[border-color,box-shadow] duration-150",
)}
/>
) : (
<button
ref={buttonRef}
type="button"
aria-label={ariaLabel}
disabled={disabled}
onClick={beginEdit}
className={cn(
fieldMetrics,
"group cursor-text border-transparent text-left transition-[background-color] duration-300 ease-out disabled:cursor-default disabled:opacity-50",
flash && "bg-emerald-500/15 transition-none",
!flash && "hover:bg-accent/60",
)}
>
<span
className={cn(
"flex-1 truncate",
value === "" && "text-muted-foreground",
)}
>
{value === "" ? placeholder : value}
</span>
<Pencil
aria-hidden
className="ml-2 size-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity duration-150 group-hover:opacity-100 group-focus-visible:opacity-100"
/>
</button>
)}
</div>
);
}