Inline Edit
Composable Inline Edit primitive for accessible read/edit workflows.
Inline Edit pairs a compact read-mode row with an editor surface, save/cancel affordances, async validation/commit, and optional blur/Enter commit behaviors via commitMode. Use InlineEdit when you supply renderDisplay and renderEditor. For bespoke layouts or Data Table wrappers, compose useInlineEdit with the InlineEdit* presentation parts (data-slot targets for styling hooks).
Built-in aria-labels resolve through useInlineEditI18n(), which reads I18nProvider when present.
Installing
pnpm dlx shadcn@latest add https://ui.isaacfei.com/r/inline-edit.jsonnpx shadcn@latest add https://ui.isaacfei.com/r/inline-edit.jsonyarn dlx shadcn@latest add https://ui.isaacfei.com/r/inline-edit.jsonbun x shadcn@latest add https://ui.isaacfei.com/r/inline-edit.jsonWith a namespace: npx shadcn@latest add @f-ui/inline-edit.
The CLI pulls button, input, lucide-react, and the fui-i18n bundle for host locale wiring.
Usage
import { InlineEdit } from '@/components/f-ui/inline-edit/inline-edit';
import { Input } from '@/components/ui/input';
<InlineEdit
defaultValue="Acme"
renderDisplay={(value) => <span className="font-medium">{value}</span>}
renderEditor={({ value, onChange, onKeyDown, onBlur }) => (
<Input
aria-label="Name"
value={value}
onChange={(e) => onChange(e.currentTarget.value)}
onKeyDown={onKeyDown}
onBlur={onBlur}
/>
)}
/>Default interaction
By default, Inline Edit uses activation="display", affordance="hover", and commitMode="manual":
- Read mode — value on the left; pencil on the right (visible on hover/focus).
- Click the value or pencil to enter edit mode (pencil hidden).
- Edit mode — editor on the left; check and cross on the right to confirm or discard. Blur and Enter do not commit unless you change
commitMode.
Use activation="trigger-only" when only the pencil should start editing, or affordance="always" / "never" to keep the pencil visible or hidden in read mode.
Commit modes
Set commitMode on InlineEdit or useInlineEdit:
| Mode | Focus leaves editor | Enter key |
|---|---|---|
manual (default) | No commit | No commit |
blur | Commits (reason: "blur") | No commit |
enter | No commit | Commits (reason: "enter") |
blur-or-enter | Commits | Commits |
Escape always cancels. onBlur handlers on your input must invoke the hook onBlur so blur commits fire.
Examples
renderEditor accepts any control: map value / onChange to your field, forward onKeyDown for Escape / Enter commit behavior, and onBlur when you use a commitMode that commits on blur. Portaled surfaces (Select, Popover, …) often work most predictably with commitMode="manual" so stray focus events don’t commit early; in dense layouts (for example data-table cells) add onPointerDown={(e) => e.stopPropagation() on interactive nodes so parent clicks don’t swallow the interaction.
Text Input
Controlled string, inline validation, commit updates parent state.
"use client";
import { useState } from "react";
import { InlineEdit } from "@/components/f-ui/inline-edit/inline-edit";
import { Input } from "@/components/ui/input";
export function InlineEditBasicDemo() {
const [name, setName] = useState("Acme Corp");
return (
<InlineEdit
value={name}
onCommit={({ value }) => setName(value)}
validate={(value) =>
value.trim().length === 0
? { valid: false, error: "Name is required." }
: { valid: true }
}
renderDisplay={(value) => <span className="font-medium">{value}</span>}
renderEditor={({ value, onChange, onKeyDown, onBlur }) => (
<Input
aria-label="Company name"
value={value}
onChange={(event) => onChange(event.currentTarget.value)}
onKeyDown={onKeyDown}
onBlur={onBlur}
className="h-8 w-56"
/>
)}
/>
);
}Select
Radix <Select> with commitMode="manual" — choose a value, then Save (blur-based commit is a poor fit while the list is portaled).
"use client";
import { useMemo, useState } from "react";
import { InlineEdit } from "@/components/f-ui/inline-edit/inline-edit";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const STATUS = [
{ value: "draft", label: "Draft" },
{ value: "review", label: "In review" },
{ value: "published", label: "Published" },
] as const;
export function InlineEditSelectDemo() {
const [status, setStatus] = useState<string>("review");
const label = useMemo(
() => STATUS.find((s) => s.value === status)?.label ?? status,
[status],
);
return (
<InlineEdit
value={status}
commitMode="manual"
onCommit={({ value }) => setStatus(value)}
renderDisplay={() => (
<span className="font-medium">
<span className="text-muted-foreground">Status: </span>
{label}
</span>
)}
renderEditor={({ value, onChange, onKeyDown }) => (
<Select
value={value}
onValueChange={onChange}
>
<SelectTrigger
aria-label="Document status"
className="h-8 w-56"
onKeyDown={onKeyDown}
onPointerDown={(e) => e.stopPropagation()}
>
<SelectValue placeholder="Choose status" />
</SelectTrigger>
<SelectContent position="popper">
{STATUS.map((s) => (
<SelectItem key={s.value} value={s.value}>
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
);
}Combobox
Popover + Command search list; the list closes on pick, CommandInput forwards onKeyDown so Escape still cancels editing.
"use client";
import { useMemo, useState } from "react";
import { ChevronsUpDownIcon } from "lucide-react";
import { InlineEdit } from "@/components/f-ui/inline-edit/inline-edit";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
const FRAMES = [
{ value: "next", label: "Next.js" },
{ value: "vite", label: "Vite" },
{ value: "astro", label: "Astro" },
{ value: "tanstack", label: "TanStack Start" },
{ value: "nuxt", label: "Nuxt" },
] as const;
export function InlineEditComboboxDemo() {
const [framework, setFramework] = useState<string>("vite");
const [open, setOpen] = useState(false);
const label = useMemo(
() => FRAMES.find((f) => f.value === framework)?.label ?? framework,
[framework],
);
return (
<InlineEdit
value={framework}
commitMode="manual"
onCommit={({ value }) => setFramework(value)}
renderDisplay={() => (
<span className="font-medium">
<span className="text-muted-foreground">Framework: </span>
{label}
</span>
)}
renderEditor={({ value, onChange, onKeyDown }) => (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
aria-label="Choose framework"
className="h-8 w-56 justify-between font-normal"
onPointerDown={(e) => e.stopPropagation()}
>
<span className="truncate">
{FRAMES.find((frame) => frame.value === value)?.label ?? "Pick…"}
</span>
<ChevronsUpDownIcon className="size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-(--radix-popover-trigger-width) p-0"
align="start"
>
<Command className="rounded-lg!">
<CommandInput
placeholder="Search…"
onKeyDown={onKeyDown}
/>
<CommandList>
<CommandEmpty>No match.</CommandEmpty>
<CommandGroup>
{FRAMES.map((frame) => (
<CommandItem
key={frame.value}
value={`${frame.value} ${frame.label}`}
className={cn(value === frame.value && "bg-muted")}
onSelect={() => {
onChange(frame.value);
setOpen(false);
}}
>
{frame.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)}
/>
);
}Number
<Input type="number"> with integer validation and commitMode="blur-or-enter".
"use client";
import { useState } from "react";
import { InlineEdit } from "@/components/f-ui/inline-edit/inline-edit";
import { NumberInput } from "@/components/f-ui/number-input/number-input";
export function InlineEditNumberDemo() {
const [units, setUnits] = useState(24);
return (
<InlineEdit
value={units}
commitMode="blur-or-enter"
onCommit={({ value }) => setUnits(value)}
validate={(value) =>
!Number.isInteger(value) || value < 1 || value > 999
? { valid: false, error: "Enter an integer from 1–999." }
: { valid: true }
}
renderDisplay={(value) => (
<span className="font-medium tabular-nums">
<span className="text-muted-foreground">Units: </span>
{value}
</span>
)}
renderEditor={({ value, onChange, onKeyDown, onBlur }) => (
<div
className="w-28"
onPointerDown={(e) => e.stopPropagation()}
>
<NumberInput
aria-label="Units in stock"
classNames={{ group: "h-8" }}
controls="split"
min={1}
max={999}
step={1}
value={value}
onValueChange={(next) => {
if (next != null) onChange(next);
}}
onKeyDown={onKeyDown}
onBlur={onBlur}
/>
</div>
)}
/>
);
}Textarea
Multiline notes with commitMode="manual" so Enter inserts a newline; Escape is handled via the forwarded onKeyDown.
Multiline fields work best with manual commit so Enter stays a newline; wire Escape on the textarea as needed (the root still receives cancel from the buttons).
"use client";
import { useState } from "react";
import { InlineEdit } from "@/components/f-ui/inline-edit/inline-edit";
import { Textarea } from "@/components/ui/textarea";
export function InlineEditTextareaDemo() {
const [notes, setNotes] = useState(
"Ship with padded envelope.\nCall before delivery.",
);
return (
<div className="max-w-md">
<InlineEdit
value={notes}
commitMode="manual"
onCommit={({ value }) => setNotes(value)}
renderDisplay={(value) => (
<span className="text-sm leading-snug whitespace-pre-wrap">
{value || (
<span className="text-muted-foreground italic">Add notes…</span>
)}
</span>
)}
renderEditor={({ value, onChange, onBlur, onKeyDown }) => (
<Textarea
aria-label="Shipping notes"
className="min-h-24 text-sm"
value={value}
onChange={(event) => onChange(event.currentTarget.value)}
onBlur={onBlur}
onPointerDown={(e) => e.stopPropagation()}
onKeyDown={onKeyDown}
/>
)}
classNames={{
root: "flex-col items-stretch gap-2",
actions: "justify-end",
}}
/>
<p className="text-muted-foreground mt-2 text-xs">
Multiline fields work best with <strong>manual</strong> commit so Enter
stays a newline; wire <strong>Escape</strong> on the textarea as needed
(the root still receives cancel from the buttons).
</p>
</div>
);
}Locked vs Disabled
Read mode is a fill-in field in disguise, so a readOnly field renders a lock in the pencil’s slot — one channel, two values, the way a Salesforce record detail shows a pencil on editable fields and a lock on non-editable ones. Without it a locked field is indistinguishable from a system fact.
The lock follows the same affordance as the pencil (there is no lock-specific switch), and the locked value stays focusable so the mark is reachable by keyboard, not hover only. disabled means a dependency is not met and renders neither glyph. Pass lockReason for host-owned copy under the value; it never gates the mark. Full matrix across forms and tables: Field Lock Affordance.
Unlocks after the contract is countersigned
"use client";
import { useState } from "react";
import { InlineEdit } from "@/components/f-ui/inline-edit/inline-edit";
import { Input } from "@/components/ui/input";
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="grid grid-cols-[8rem_1fr] items-center gap-3 py-1">
<span className="text-muted-foreground text-sm">{label}</span>
{children}
</div>
);
}
export function InlineEditLockDemo() {
const [owner, setOwner] = useState("Dana Lin");
return (
<div className="max-w-lg">
<Row label="Owner">
<InlineEdit
value={owner}
affordance="always"
onCommit={({ value }) => setOwner(value)}
renderDisplay={(value) => <span>{value}</span>}
renderEditor={({ value, onChange, onKeyDown, onBlur }) => (
<Input
aria-label="Owner"
value={value}
onChange={(event) => onChange(event.currentTarget.value)}
onKeyDown={onKeyDown}
onBlur={onBlur}
/>
)}
/>
</Row>
<Row label="Contract ref">
<InlineEdit
value="CTR-4471"
readOnly
affordance="always"
lockReason="Unlocks after the contract is countersigned"
renderDisplay={(value) => <span>{value}</span>}
renderEditor={({ value }) => <Input value={value} readOnly />}
/>
</Row>
<Row label="Renewal term">
<InlineEdit
value="12 months"
disabled
affordance="always"
renderDisplay={(value) => <span>{value}</span>}
renderEditor={({ value }) => <Input value={value} readOnly />}
/>
</Row>
</div>
);
}Headless Usage
useInlineEdit is the view-model: edit lifecycle, validation, and rootProps / displayProps / triggerProps / editorProps / saveButtonProps / cancelButtonProps bags. The demo spreads those onto native <span>, <input>, and <button> — not InlineEditRoot, InlineEditDisplay, or Input.
value: Notebook
"use client";
import { useInlineEdit } from "@/components/f-ui/inline-edit/use-inline-edit";
export function InlineEditHeadlessDemo() {
const ie = useInlineEdit({ defaultValue: "Notebook" });
return (
<div {...ie.rootProps}>
{!ie.state.isEditing ? (
<div>
<span {...ie.displayProps}>{ie.state.value}</span>
<button {...ie.triggerProps} type="button">
Edit
</button>
</div>
) : (
<div>
<input
aria-label="Notebook title"
value={String(ie.editorProps.value)}
onChange={(event) => ie.editorProps.onChange(event.currentTarget.value)}
onKeyDown={ie.editorProps.onKeyDown}
onBlur={ie.editorProps.onBlur}
/>
{ie.state.error ? (
<span {...ie.errorProps}>{ie.state.error}</span>
) : null}
<button {...ie.saveButtonProps} type="button">
Save
</button>
<button {...ie.cancelButtonProps} type="button">
Cancel
</button>
</div>
)}
<p>value: {String(ie.state.value)}</p>
</div>
);
}Composition
InlineEdit
├── InlineEditRoot (data-editing / data-dirty)
├── read: InlineEditDisplay (clickable when activation="display", focusable when readOnly)
│ + InlineEditTrigger (pencil) OR FieldLockMark (readOnly) — same slot, same affordance
│ + optional lock reason text
└── edit: InlineEditEditor (renderEditor + optional InlineEditErrorText)
+ optional InlineEditActions (save + cancel, or cancel-only)API Reference
InlineEdit props
renderDisplay | (value: TValue) => ReactNode — read mode body. |
renderEditor | Receives value, onChange, onKeyDown, onBlur from useInlineEdit. Wire them to your control (Input, Select, Textarea, Popover + Command, …). |
lockReason | ReactNode — why this readOnly field is locked. Static text under the value, linked by aria-describedby. Ignored unless readOnly; never gates the lock mark. |
classNames | Slot class map: root, display, trigger, editor, actions, saveButton, cancelButton, errorText. |
locale, t | Optional Inline Edit translator overrides (see useInlineEditI18n). |
activation | Default "display". "display" makes the read value clickable; "trigger-only" requires the pencil. |
affordance | Default "hover". "hover" hides the pencil until hover/focus-within; "always" keeps it visible; "never" hides it (pair with activation="display"). showEditTrigger={false} hides the pencil regardless. |
showEditTrigger | Default true. When false, hides the pencil trigger regardless of affordance. |
inlineActions | save-and-cancel (default), cancel-only, or none. Data Table batch editing uses cancel-only with table-owned edit sessions; Save all on the editing bar persists. |
All UseInlineEditOptions keys apply (validate, onCommit, onCancel, commitMode, value / defaultValue, controlled isEditing, disabled, readOnly, etc.).
useInlineEdit return
state | value, draftValue, lastCommittedValue, isEditing, isDirty, isValidating, isCommitting, error. |
actions | startEditing, setDraftValue, validate, commit, cancel, reset. |
*Props bags | Headless bindings for primitives and data-* hints on rootProps. |