# Input Inline Edit — React Input Component

> Text that turns into a field in place with no layout shift; Enter or blur saves with a brief tick, Escape puts the old value back.

- Category: [Inputs](https://pixelpapa.com/components/inputs.md)
- Tags: input, inline, edit, text, form
- Dependencies: lucide-react, motion (plus React and Tailwind CSS v4)
- Last updated: 2026-11-13
- Live preview: https://pixelpapa.com/components/inputs/input-inline-edit

## Install

```bash
npx shadcn@latest add "https://pixelpapa.com/r/input-inline-edit.json"
```

The file is written to `components/pixelpapa/input-inline-edit.tsx`. To install manually, run `npm install lucide-react motion` and copy the source below into your project. It imports `cn` from `@/lib/utils`, which every shadcn project has.

## Source

```tsx
"use client";

import { Check, Pencil } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
import { cn } from "@/lib/utils";

type InlineEditProps = {
  value?: string;
  defaultValue?: string;
  /** Called when an edit is committed (Enter or blur), not on every keystroke. */
  onChange?: (value: string) => void;
  /** Accessible name, e.g. "Project name". */
  label: string;
  placeholder?: string;
  /** Typography classes; the text and the field share them. */
  className?: string;
};

export function InlineEdit({
  value,
  defaultValue = "",
  onChange,
  label,
  placeholder = "Add text…",
  className,
}: InlineEditProps) {
  const [inner, setInner] = useState(defaultValue);
  const [editing, setEditing] = useState(false);
  const [draft, setDraft] = useState("");
  const [saved, setSaved] = useState(0);
  const inputRef = useRef<HTMLInputElement>(null);
  const buttonRef = useRef<HTMLButtonElement>(null);
  const refocus = useRef(false);
  const closed = useRef(true); // Guards against the blur that follows Enter/Escape.
  const text = value ?? inner;

  useEffect(() => {
    if (editing) inputRef.current?.select();
    else if (refocus.current) {
      refocus.current = false;
      buttonRef.current?.focus();
    }
  }, [editing]);

  useEffect(() => {
    if (!saved) return;
    const t = setTimeout(() => setSaved(0), 1400);
    return () => clearTimeout(t);
  }, [saved]);

  function finish(save: boolean, viaKeyboard: boolean) {
    if (closed.current) return;
    closed.current = true;
    refocus.current = viaKeyboard;
    setEditing(false);
    const next = draft.trim();
    if (!save || next === text) return;
    if (value === undefined) setInner(next);
    onChange?.(next);
    setSaved((n) => n + 1);
  }

  function onKeyDown(e: KeyboardEvent<HTMLInputElement>) {
    if (e.key === "Enter") finish(true, true);
    else if (e.key === "Escape") finish(false, true);
  }

  // Both modes share padding and font, so swapping them never moves the text.
  const box = "-mx-2 rounded-lg px-2 py-1 [font:inherit] tracking-[inherit]";

  return (
    <span className={cn("flex max-w-full items-center gap-2", className)}>
      {editing ? (
        <span className={cn(box, "inline-grid min-w-0 bg-background ring-2 ring-ring")}>
          <span aria-hidden className="invisible col-start-1 row-start-1 truncate whitespace-pre">
            {draft || placeholder}
          </span>
          <input
            ref={inputRef}
            value={draft}
            aria-label={label}
            placeholder={placeholder}
            onChange={(e) => setDraft(e.target.value)}
            onKeyDown={onKeyDown}
            onBlur={() => finish(true, false)}
            className="col-start-1 row-start-1 w-full min-w-0 bg-transparent text-foreground outline-none [font:inherit] tracking-[inherit] placeholder:text-muted-foreground/60"
          />
        </span>
      ) : (
        <button
          ref={buttonRef}
          type="button"
          aria-label={`Edit ${label}: ${text || "empty"}`}
          onClick={() => {
            closed.current = false;
            setDraft(text);
            setEditing(true);
          }}
          className={cn(
            box,
            "group flex min-w-0 items-center gap-2 text-left transition-colors hover:bg-muted",
            !text && "text-muted-foreground/70",
          )}
        >
          <span className="truncate">{text || placeholder}</span>
          <Pencil className="size-[0.8em] shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100" />
        </button>
      )}
      <AnimatePresence>
        {saved > 0 && !editing && (
          <motion.span
            key={saved}
            role="status"
            initial={{ opacity: 0, scale: 0.4, x: -6 }}
            animate={{ opacity: 1, scale: 1, x: 0 }}
            exit={{ opacity: 0, scale: 0.6 }}
            transition={{ type: "spring", stiffness: 500, damping: 26 }}
            className="flex size-5 shrink-0 items-center justify-center rounded-full bg-emerald-500 text-white"
          >
            <Check className="size-3" strokeWidth={3} />
            <span className="sr-only">Saved</span>
          </motion.span>
        )}
      </AnimatePresence>
    </span>
  );
}

export default function InputInlineEditDemo() {
  const [title, setTitle] = useState("Harbor rebrand, phase two");

  return (
    <div className="w-full max-w-md rounded-2xl border border-border bg-card p-6 shadow-sm">
      <p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">Project</p>
      <InlineEdit
        label="Project name"
        value={title}
        onChange={setTitle}
        className="mt-1 text-2xl font-semibold tracking-tight text-card-foreground"
      />
      <InlineEdit
        label="Description"
        defaultValue="Logo, type system and launch kit for the spring release."
        placeholder="Add a description…"
        className="mt-1 text-sm text-muted-foreground"
      />
      <dl className="mt-5 grid grid-cols-[5rem_1fr] items-center gap-y-1 border-t border-border pt-4 text-sm">
        <dt className="text-muted-foreground">Client</dt>
        <dd>
          <InlineEdit label="Client" defaultValue="Harbor & Vale" className="font-medium text-card-foreground" />
        </dd>
        <dt className="text-muted-foreground">Budget</dt>
        <dd>
          <InlineEdit label="Budget" defaultValue="" placeholder="Set a budget" className="font-medium text-card-foreground" />
        </dd>
      </dl>
      <p className="mt-4 text-xs text-muted-foreground">Click any text to edit · Enter saves · Esc cancels</p>
    </div>
  );
}
```

## AI prompt

```text
Add the "Input Inline Edit" component from PixelPapa UI to this project.

What it is: Text that turns into a field in place with no layout shift; Enter or blur saves with a brief tick, Escape puts the old value back.
Category: Inputs. Depends on: lucide-react, motion.

1. Install it with the shadcn CLI:
   npx shadcn@latest add "https://pixelpapa.com/r/input-inline-edit.json"
   (If the CLI is unavailable, fetch that URL, write files[0].content to components/pixelpapa/input-inline-edit.tsx and install the listed dependencies.)
2. Import it from "@/components/pixelpapa/input-inline-edit" and place it where I describe below.
3. Keep its animation timings and accessibility attributes. Replace the demo copy and data with content that fits my product, and use my existing theme tokens (bg-background, text-foreground, bg-primary, border-border) rather than hard-coded colours.

Where to use it: <describe the page and position here>
```

## More inputs

- [Input Credit Card](https://pixelpapa.com/components/inputs/input-credit-card.md) (Pro): A payment form with a live card preview: digits group in fours, a highlight glides to the part you are editing, and the card flips over for the security code.
- [Input Currency Format](https://pixelpapa.com/components/inputs/input-currency-format.md) (Pro): A large amount field that groups thousands as you type without losing the caret, pads cents on blur, and grows to hug its digits.
- [Input Floating Label](https://pixelpapa.com/components/inputs/input-floating-label.md): Text fields whose label rests as a placeholder, then springs up into a small caption on focus or when filled; inset and underline variants.
- [Input Mention Autocomplete](https://pixelpapa.com/components/inputs/input-mention-autocomplete.md) (Pro): A comment box that opens a people list when you type @, filters as you go, inserts the handle with Enter or Tab and tints finished mentions inline.
- [Input Number Stepper](https://pixelpapa.com/components/inputs/input-number-stepper.md) (Pro): A spinbutton whose digits roll up or down like an odometer; hold a button to repeat with acceleration, or use the arrow keys.
- [Input OTP Boxes](https://pixelpapa.com/components/inputs/input-otp-boxes.md) (Pro): One-time-code boxes that auto-advance, accept a pasted code, step back on Backspace, pop each digit in and shake or hop on error or success.
