Something new is coming.Join the waitlist

Inline Edit Animation

PreviousNext

React inline edit component. Reveal pencil/confirm/cancel on hover, animate between view and edit modes with spring transitions. Composable, accessible, shadcn-compatible.

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

PropTypeDefaultDescription
iconFC<{ size?: number; className?: string }>Lucide (or any size-prop icon) shown in the label column. Required.
labelstringAccessible label text. Rendered as a <label> linked to the input. Required.
valuestringCommitted value displayed when not editing. Required.
secondaryValuestringSecond field value — only used when type="time".
onSave(value: string) => voidCalled with the edited value on confirm. Used for all types except "time".
onSaveRange(v1: string, v2: string) => voidCalled 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.
multilinebooleanfalseRenders 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.