f-ui
Components

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.json
npx shadcn@latest add https://ui.isaacfei.com/r/inline-edit.json
yarn dlx shadcn@latest add https://ui.isaacfei.com/r/inline-edit.json
bun x shadcn@latest add https://ui.isaacfei.com/r/inline-edit.json

With 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":

  1. Read mode — value on the left; pencil on the right (visible on hover/focus).
  2. Click the value or pencil to enter edit mode (pencil hidden).
  3. 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:

ModeFocus leaves editorEnter key
manual (default)No commitNo commit
blurCommits (reason: "blur")No commit
enterNo commitCommits (reason: "enter")
blur-or-enterCommitsCommits

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.

Acme Corp
"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).

Status: In review
"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.

Framework: Vite
"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".

Units: 24
"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.

Ship with padded envelope. Call before delivery.

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.

Owner
Dana Lin
Contract ref
CTR-4471

Unlocks after the contract is countersigned

Renewal term
12 months
"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.

Notebook

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.
renderEditorReceives value, onChange, onKeyDown, onBlur from useInlineEdit. Wire them to your control (Input, Select, Textarea, Popover + Command, …).
lockReasonReactNode — why this readOnly field is locked. Static text under the value, linked by aria-describedby. Ignored unless readOnly; never gates the lock mark.
classNamesSlot class map: root, display, trigger, editor, actions, saveButton, cancelButton, errorText.
locale, tOptional Inline Edit translator overrides (see useInlineEditI18n).
activationDefault "display". "display" makes the read value clickable; "trigger-only" requires the pencil.
affordanceDefault "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.
showEditTriggerDefault true. When false, hides the pencil trigger regardless of affordance.
inlineActionssave-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

statevalue, draftValue, lastCommittedValue, isEditing, isDirty, isValidating, isCommitting, error.
actionsstartEditing, setDraftValue, validate, commit, cancel, reset.
*Props bagsHeadless bindings for primitives and data-* hints on rootProps.

On this page