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 FuiI18nProvider 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((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".

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

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>
  );
}

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.

Notebook
"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.
renderEditorReceives value, onChange, onKeyDown, onBlur from useInlineEdit. Wire them to your control (Input, Select, Textarea, Popover + Command, …).
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