Project Brief
Installation
npx shadcn@latest add https://tentui.com/r/inline-edit.json
Usage
EditableRow is a low-level primitive. Compose as many rows as you need inside any container — the card shell, header, and layout are entirely up to you.
"use client";
import { AlignLeft, Calendar, Ticket } from "lucide-react";
import { useState } from "react";
import { EditableRow } from "@/components/inline-edit";
interface Task {
name: string;
due: string;
notes: string;
}
export default function TaskCard() {
const [task, setTask] = useState<Task>({
name: "Finalize design tokens",
due: "Friday, May 9, 2026",
notes: "Cover color, spacing, and typography scales.",
});
return (
<div className="rounded-2xl border border-border bg-background p-4 space-y-1">
<EditableRow
icon={Ticket}
label="Task"
value={task.name}
onSave={(v) => setTask((t) => ({ ...t, name: v }))}
/>
<EditableRow
icon={Calendar}
label="Due"
value={task.due}
onSave={(v) => setTask((t) => ({ ...t, due: v }))}
/>
<EditableRow
icon={AlignLeft}
label="Notes"
multiline
value={task.notes}
onSave={(v) => setTask((t) => ({ ...t, notes: v }))}
/>
</div>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
icon | FC<{ size?: number; className?: string }> | — | Lucide (or any size-prop icon) shown in the label column. Required. |
label | string | — | Accessible label text. Rendered as a <label> linked to the input. Required. |
value | string | — | Committed value displayed when not editing. Required. |
secondaryValue | string | — | Second field value — only used when type="time". |
onSave | (value: string) => void | — | Called with the edited value on confirm. Used for all types except "time". |
onSaveRange | (v1: string, v2: string) => void | — | Called with both field values on confirm. Used when type="time". |
type | "text" | "time" | "url" | "text" | "time" renders two side-by-side inputs. "url" sets type="url" on the input. |
multiline | boolean | false | Renders a <textarea> instead of an <input> and stacks the label above. |
How it works
Each EditableRow owns its own draft state (v1, v2). On mount the draft is seeded from value / secondaryValue. The row renders in read-only mode — the pencil button is invisible until the row is hovered (opacity-0 group-hover/content:opacity-100).
Clicking the pencil sets editing = true. The pencil swaps out for confirm and cancel buttons via AnimatePresence mode="popLayout" — the outgoing element slides down and fades before the incoming one arrives, so the two never overlap. The layout shift animates with a spring (stiffness: 420, damping: 28).
Confirming calls onSave or onSaveRange with the draft values. Cancelling resets the draft back to the last committed value prop without calling any callback.
Patterns
Range field (start / end time)
Use type="time" with secondaryValue and onSaveRange to get two side-by-side inputs that save together.
<EditableRow
icon={Clock}
label="Time"
type="time"
value={event.start}
secondaryValue={event.end}
onSaveRange={(start, end) =>
setEvent((e) => ({ ...e, start, end }))
}
/>Multiline notes field
multiline swaps the input for a <textarea> and stacks the label above the field — better for longer free-form text.
<EditableRow
icon={AlignLeft}
label="Description"
multiline
value={item.desc}
onSave={(v) => setItem((i) => ({ ...i, desc: v }))}
/>Persisting to a backend
Use an optimistic update pattern — commit locally first, then fire the server action in a transition so the UI stays responsive.
"use client";
import { Ticket } from "lucide-react";
import { useState, useTransition } from "react";
import { EditableRow } from "@/components/inline-edit";
import { updateTaskName } from "@/actions/tasks";
export function TaskNameRow({ initial }: { initial: string }) {
const [name, setName] = useState(initial);
const [, startTransition] = useTransition();
const handleSave = (next: string) => {
setName(next);
startTransition(() => updateTaskName(next));
};
return (
<EditableRow icon={Ticket} label="Task" value={name} onSave={handleSave} />
);
}Enter to confirm
Single-line text and url rows already handle Enter — pressing it triggers the same save path as clicking the confirm button. multiline rows intentionally skip this so the Enter key inserts a newline.