Inputs & Forms

Inline Edit

Click-to-edit text with a hover pencil affordance, a metric-identical input swap with zero layout shift, and a brief success flash on commit.

Install

npx shadcn@latest add @paragon/inline-edit

inline-edit.tsx

"use client";

import * as React from "react";
import { Pencil } from "lucide-react";
import { cn } from "@/lib/utils";

export interface InlineEditProps
  extends Omit<React.ComponentProps<"div">, "defaultValue" | "onChange"> {
  /** Controlled value. */
  value?: string;
  /** Initial value when uncontrolled. */
  defaultValue?: string;
  /** Called when an edit is committed (Enter or blur). */
  onValueChange?: (value: string) => void;
  placeholder?: string;
  disabled?: boolean;
  /** Accessible name for the field, applied to both view and edit modes. */
  "aria-label"?: string;
}

/** Metrics shared by view and edit modes so swapping causes zero layout shift. */
const fieldMetrics =
  "flex h-8 w-full items-center rounded-md border px-2 text-sm";

/**
 * Click-to-edit text. View mode reveals a pencil affordance on hover;
 * edit mode swaps to an input with identical metrics. Enter commits with
 * a brief success flash, Escape reverts.
 */
export function InlineEdit({
  value: valueProp,
  defaultValue = "",
  onValueChange,
  placeholder = "Empty",
  disabled = false,
  className,
  "aria-label": ariaLabel,
  ...props
}: InlineEditProps) {
  const [uncontrolled, setUncontrolled] = React.useState(defaultValue);
  const value = valueProp ?? uncontrolled;
  const [editing, setEditing] = React.useState(false);
  const [draft, setDraft] = React.useState("");
  const [flash, setFlash] = React.useState(false);
  const buttonRef = React.useRef<HTMLButtonElement>(null);
  const flashTimer = React.useRef<ReturnType<typeof setTimeout>>(null);
  const escaped = React.useRef(false);

  React.useEffect(() => {
    return () => {
      if (flashTimer.current) clearTimeout(flashTimer.current);
    };
  }, []);

  const beginEdit = () => {
    setDraft(value);
    escaped.current = false;
    setEditing(true);
  };

  const commit = (refocus: boolean) => {
    setEditing(false);
    const next = draft.trim();
    if (next !== "" && next !== value) {
      if (valueProp === undefined) setUncontrolled(next);
      onValueChange?.(next);
      // Paint the flash instantly, then let it fade out via transition.
      setFlash(true);
      if (flashTimer.current) clearTimeout(flashTimer.current);
      flashTimer.current = setTimeout(() => setFlash(false), 150);
    }
    if (refocus) {
      // Return focus to the trigger after the input unmounts.
      requestAnimationFrame(() => buttonRef.current?.focus());
    }
  };

  const revert = () => {
    escaped.current = true;
    setEditing(false);
    requestAnimationFrame(() => buttonRef.current?.focus());
  };

  return (
    <div className={cn("w-full min-w-0", className)} {...props}>
      {editing ? (
        <input
          type="text"
          autoFocus
          aria-label={ariaLabel}
          value={draft}
          onChange={(e) => setDraft(e.target.value)}
          onFocus={(e) => e.target.select()}
          onKeyDown={(e) => {
            if (e.nativeEvent.isComposing) return;
            if (e.key === "Enter") {
              e.preventDefault();
              commit(true);
            } else if (e.key === "Escape") {
              e.preventDefault();
              revert();
            }
          }}
          onBlur={() => {
            if (!escaped.current) commit(false);
          }}
          className={cn(
            fieldMetrics,
            "border-input bg-transparent outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 transition-[border-color,box-shadow] duration-150",
          )}
        />
      ) : (
        <button
          ref={buttonRef}
          type="button"
          aria-label={ariaLabel}
          disabled={disabled}
          onClick={beginEdit}
          className={cn(
            fieldMetrics,
            "group cursor-text border-transparent text-left transition-[background-color] duration-300 ease-out disabled:cursor-default disabled:opacity-50",
            flash && "bg-emerald-500/15 transition-none",
            !flash && "hover:bg-accent/60",
          )}
        >
          <span
            className={cn(
              "flex-1 truncate",
              value === "" && "text-muted-foreground",
            )}
          >
            {value === "" ? placeholder : value}
          </span>
          <Pencil
            aria-hidden
            className="ml-2 size-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity duration-150 group-hover:opacity-100 group-focus-visible:opacity-100"
          />
        </button>
      )}
    </div>
  );
}