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 FuiI18nProvider 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((f) => f.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((f) => (
<CommandItem
key={f.value}
value={`${f.value} ${f.label}`}
className={cn(value === f.value && "bg-muted")}
onSelect={() => {
onChange(f.value);
setOpen(false);
}}
>
{f.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 { Input } from "@/components/ui/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 }) => (
<Input
type="number"
min={1}
max={999}
step={1}
aria-label="Units in stock"
className="h-8 w-28"
value={String(value)}
onChange={(event) => {
const raw = event.currentTarget.value;
if (raw === "") return;
const next = Number.parseInt(raw, 10);
if (!Number.isNaN(next)) onChange(next);
}}
onKeyDown={onKeyDown}
onBlur={onBlur}
onPointerDown={(e) => e.stopPropagation()}
/>
)}
/>
);
}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>
);
}Headless Usage
Call useInlineEdit(options) and spread rootProps, displayProps, triggerProps, editorProps, saveButtonProps, cancelButtonProps, and errorProps onto the matching parts—the lifecycle matches the InlineEdit container; below, InlineEditRoot wraps a vertical layout with InlineEditDisplay / InlineEditTrigger in read mode and InlineEditEditor plus InlineEditActions when editing. Pair with useInlineEditI18n() for default English/Chinese strings or host overrides.
"use client";
import { useInlineEditI18n } from "@/components/f-ui/inline-edit/hooks/use-inline-edit-i18n";
import { InlineEditActions } from "@/components/f-ui/inline-edit/inline-edit-parts/inline-edit-actions";
import { InlineEditCancelButton } from "@/components/f-ui/inline-edit/inline-edit-parts/inline-edit-cancel-button";
import { InlineEditDisplay } from "@/components/f-ui/inline-edit/inline-edit-parts/inline-edit-display";
import { InlineEditEditor } from "@/components/f-ui/inline-edit/inline-edit-parts/inline-edit-editor";
import { InlineEditErrorText } from "@/components/f-ui/inline-edit/inline-edit-parts/inline-edit-error-text";
import { InlineEditRoot } from "@/components/f-ui/inline-edit/inline-edit-parts/inline-edit-root";
import { InlineEditSaveButton } from "@/components/f-ui/inline-edit/inline-edit-parts/inline-edit-save-button";
import { InlineEditTrigger } from "@/components/f-ui/inline-edit/inline-edit-parts/inline-edit-trigger";
import { useInlineEdit } from "@/components/f-ui/inline-edit/use-inline-edit";
import { Input } from "@/components/ui/input";
export function InlineEditHeadlessDemo() {
const ie = useInlineEdit({ defaultValue: "Notebook" });
const { t } = useInlineEditI18n();
return (
<div className="max-w-md">
<InlineEditRoot
{...ie.rootProps}
className="flex-col items-stretch gap-3 rounded-lg border bg-card p-3"
>
{!ie.state.isEditing ? (
<div className="flex items-center justify-between gap-2">
<InlineEditDisplay
{...ie.displayProps}
className="flex-1 text-sm font-medium"
>
{ie.state.value}
</InlineEditDisplay>
<InlineEditTrigger
{...ie.triggerProps}
aria-label={t("action.edit")}
/>
</div>
) : (
<>
<InlineEditEditor className="flex flex-col gap-2">
<Input
aria-label="Notebook title"
value={ie.editorProps.value}
onChange={(event) =>
ie.editorProps.onChange(event.currentTarget.value)
}
onKeyDown={ie.editorProps.onKeyDown}
onBlur={ie.editorProps.onBlur}
className="h-8"
/>
{ie.state.error ? (
<InlineEditErrorText {...ie.errorProps}>
{ie.state.error}
</InlineEditErrorText>
) : null}
</InlineEditEditor>
<InlineEditActions>
<InlineEditSaveButton
{...ie.saveButtonProps}
aria-label={t("action.save")}
/>
<InlineEditCancelButton
{...ie.cancelButtonProps}
aria-label={t("action.cancel")}
/>
</InlineEditActions>
</>
)}
</InlineEditRoot>
</div>
);
}Composition
InlineEdit
├── InlineEditRoot (data-editing / data-dirty)
├── read: InlineEditDisplay (clickable when activation="display") + optional InlineEditTrigger (affordance hover/always/never)
└── 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, …). |
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. |