Inline edit
Text that turns into a field in place, saving on Enter and putting itself back on Escape.
Enter saves, Escape cancels and puts focus back on the display button, and leaving the field saves too. An unchanged value never reaches onSave. A validation message keeps the editor open, marks the field invalid, is tied to it with aria-describedby, and is said aloud as it lands (role="alert"): after Enter the focus stays in the field, where a description is not read again. A rejected promise from onSave is surfaced the same way. The aria-label names the field: the display button reads as Edit plus that name, or plus the text itself when there is no label.
Install
$
npx shadcn@latest add @vibra/inline-editNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { InlineEdit } from "@/components/ui/inline-edit"
export default function InlineEditDemo() {
const [name, setName] = React.useState("Northwind analytics")
const [summary, setSummary] = React.useState(
"Revenue, retention, and pipeline health for the North America team."
)
return (
<div className="flex w-full max-w-sm flex-col gap-4">
<div className="flex flex-col gap-1">
<span className="text-xs font-medium text-muted-foreground">Workspace name</span>
<InlineEdit
aria-label="Workspace name"
value={name}
onSave={async (next) => {
await new Promise((resolve) => setTimeout(resolve, 600))
setName(next)
}}
validate={(next) => (next.trim() ? null : "Enter a workspace name")}
/>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs font-medium text-muted-foreground">Summary</span>
<InlineEdit
aria-label="Summary"
value={summary}
multiline
placeholder="Add a summary"
onSave={setSummary}
/>
</div>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | — | The text as it stands; the caller applies what onSave reports. |
| onSave | (value: string) => void | Promise<void> | — | Called only when the text actually changed; a promise shows a spinner until it settles. |
| placeholder | string | "Empty" | Stands in for an empty value, in the display and in the field. |
| multiline | boolean | false | Edits in a textarea, where Enter saves and Shift+Enter starts a new line. |
| validate | (value: string) => string | null | — | Returns a message to block the save, or null to allow it. |
| renderDisplay | (value: string) => React.ReactNode | — | Renders the resting state, e.g. as a badge or a formatted amount. |
| size | "sm" | "default" | "default" | sm drops the field and the display row to h-7. |
| disabled | boolean | false | Stops the display button from opening the editor. |
Dependencies
Registry
npm
Source
"use client"
import * as React from "react"
import { PencilIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Input } from "@/components/ui/input"
import { Spinner } from "@/components/ui/spinner"
import { Textarea } from "@/components/ui/textarea"
export type InlineEditProps = React.ComponentProps<"div"> & {
value: string
/** Called only when the text actually changed; may return a promise, which shows a spinner until it settles. */
onSave: (value: string) => void | Promise<void>
placeholder?: string
/** Edits in a textarea, where Enter saves and Shift+Enter starts a new line. */
multiline?: boolean
/** Returns a message to block the save, or null to allow it. */
validate?: (value: string) => string | null
renderDisplay?: (value: string) => React.ReactNode
size?: "sm" | "default"
disabled?: boolean
}
/** Text that turns into a field in place: click to edit, Enter to save, Escape to put it back. */
function InlineEdit({
className,
value,
onSave,
placeholder = "Empty",
multiline = false,
validate,
renderDisplay,
size = "default",
disabled = false,
"aria-label": ariaLabel,
...props
}: InlineEditProps) {
const errorId = React.useId()
const displayRef = React.useRef<HTMLButtonElement>(null)
const restoreFocus = React.useRef(false)
const [editing, setEditing] = React.useState(false)
const [draft, setDraft] = React.useState(value)
const [error, setError] = React.useState<string | null>(null)
const [saving, setSaving] = React.useState(false)
React.useEffect(() => {
if (!editing && restoreFocus.current) {
restoreFocus.current = false
displayRef.current?.focus()
}
}, [editing])
function open() {
if (disabled) return
setDraft(value)
setError(null)
setEditing(true)
}
function cancel() {
restoreFocus.current = true
setError(null)
setEditing(false)
}
async function commit() {
if (saving) return
// Nothing changed, so there is nothing to save and nothing to validate.
if (draft === value) {
setEditing(false)
return
}
const message = validate?.(draft) ?? null
if (message) {
setError(message)
return
}
setSaving(true)
try {
await onSave(draft)
setError(null)
setEditing(false)
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Could not save")
} finally {
setSaving(false)
}
}
function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) {
if (event.key === "Escape") {
event.preventDefault()
cancel()
return
}
if (event.key === "Enter" && !(multiline && event.shiftKey)) {
event.preventDefault()
restoreFocus.current = true
void commit()
}
}
const controlProps = {
autoFocus: true,
value: draft,
disabled: saving,
"aria-label": ariaLabel,
"aria-invalid": error ? true : undefined,
"aria-describedby": error ? errorId : undefined,
placeholder,
onChange: (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setDraft(event.target.value)
if (error) setError(null)
},
onFocus: (event: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) =>
event.currentTarget.select(),
onKeyDown: handleKeyDown,
// Leaving the field saves, but must not yank focus back to the display
// button afterwards — only Enter and Escape do that. The exception is the
// blur a browser fires when a save disables the focused field: that one is
// the save's own doing, and the focus it drops is the one Enter promised
// to give back.
onBlur: () => {
if (!saving) restoreFocus.current = false
void commit()
},
}
return (
<div
data-slot="inline-edit"
data-size={size}
data-editing={editing || undefined}
className={cn("flex w-full flex-col gap-1", className)}
{...props}
>
{editing ? (
<div className="relative flex items-center">
{multiline ? (
<Textarea {...controlProps} rows={3} className="pe-8 text-sm" />
) : (
<Input {...controlProps} className={cn("pe-8", size === "sm" ? "h-7" : "h-8")} />
)}
{saving ? (
<Spinner className="absolute end-2.5 size-3.5 text-muted-foreground" />
) : null}
</div>
) : (
<button
ref={displayRef}
type="button"
data-slot="inline-edit-display"
// Names what the button edits — the field's own label when it has one,
// the text it holds otherwise.
aria-label={`Edit ${ariaLabel || value || placeholder}`}
disabled={disabled}
onClick={open}
// The padding is the hover target, so it bleeds outside the row: the
// text itself lines up with whatever sits above and below it.
className={cn(
"group/inline-edit -mx-2 flex w-[calc(100%+1rem)] items-center gap-2 rounded-md px-2 text-start text-sm transition-colors hover:bg-accent focus-ring disabled:pointer-events-none disabled:opacity-50",
size === "sm" ? "min-h-7 py-1" : "min-h-8 py-1.5"
)}
>
<span
className={cn(
"min-w-0 flex-1",
multiline ? "whitespace-pre-wrap" : "truncate",
!value && "text-muted-foreground"
)}
>
{renderDisplay ? renderDisplay(value) : value || placeholder}
</span>
<PencilIcon
aria-hidden="true"
className="size-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover/inline-edit:opacity-100 group-focus-visible/inline-edit:opacity-100"
/>
</button>
)}
{/* Said aloud as it lands, as well as tied to the field: after Enter the
focus stays in the field, where a description is not read again. */}
{error ? (
<p id={errorId} role="alert" data-slot="inline-edit-error" className="text-xs text-danger">
{error}
</p>
) : null}
</div>
)
}
export { InlineEdit }