Editable Label

Renaming something usually means leaving for a settings screen, which hides the thing you are renaming at the moment you decide what to call it.

InputProductivityEditorFormsintermediate
  • Keyboard
  • Touch
  • Responsive
  • Reduced motion
Design Review
npx shadcn@latest add https://design-recreation.vercel.app/r/editable-label.json

The problem

Renaming something usually means opening a settings screen or a dialog, which puts the thing you are renaming out of sight at exactly the moment you are deciding what to call it. Editing where the name already sits keeps the context — and the surrounding layout — visible the whole time, and makes the commit obvious rather than implied by a Save button somewhere else.

Use cases

  • Renaming a board, project, or document from its own header
  • Editing a tag or label without opening a manager
  • Naming something that starts as "Untitled"

Not for: multi-field editing. Once a second field exists this becomes a form with no submit button, and forms want a real one.

Installation

bash
npm install motion lucide-react

Then copy EditableLabel.tsx. It depends only on @/lib/cn and @/lib/motion.

Usage

tsx
import EditableLabel from "@/patterns/editable-label/EditableLabel";

<EditableLabel
  defaultValue="Design Review"
  fieldLabel="Board name"
  onValueChange={(name) => renameBoard(name)}
/>;

onValueChange fires on commit, never on keystroke — this is a rename, not a text field.

API

PropTypeDefaultDescription
defaultValuestring""Uncontrolled initial text.
valuestringuncontrolledControlled text. Pass with onValueChange.
onValueChange(value: string) => void—Fires on commit only.
placeholderstring"Untitled"Shown when the value is empty.
fieldLabelstring"Name"Names what is being renamed, for assistive tech.
validate(draft: string) => boolean—Return false to reject and keep editing.
disabledbooleanfalseDisables the edit trigger.
restingWidthnumber220Pill width in px when not editing.
editingWidthnumber290Pill width in px while editing.
classNamestring—Merged onto the root.

Keyboard

KeyAction
TabFocus the edit button
Enter / SpaceEnter edit mode
EnterCommit the rename
EscapeCancel and restore the previous name
TabCommit and move on

Accessibility

Both controls are real buttons with names that say what they act on — "Edit board name", not "Edit". The input is labelled by fieldLabel rather than relying on a visible label that does not exist in this layout.

Focus returns to the edit button after commit or cancel. Without that, focus dies with the unmounting input and a keyboard user is dropped at the top of the document.

Entering edit mode selects the whole value, because renaming usually replaces a name rather than appending to it — the common case should cost one keystroke.

The confirm button uses onMouseDown with preventDefault before its click handler. The input's onBlur commits, and without that guard blur would fire first, unmount the button, and the click would land on nothing.

An empty rename is treated as a mistake rather than an instruction: the old value is kept. Escape restores it explicitly.

Under prefers-reduced-motion the scale and blur are dropped and the icons cross-fade.

Known limitation: blur commits rather than cancelling. That matches most rename affordances, but it means clicking elsewhere saves — if your data model makes an accidental rename expensive, pass validate or wire onValueChange to something undoable.

Performance

The container animates width between two values, restingWidth and editingWidth, which are props.

This was briefly Motion's layout prop instead, sizing the pill to its content. That was tidier and wrong. A text field has no intrinsic width, so entering edit mode collapsed the pill to almost nothing and then grew it one character at a time as you typed — and the resize, which is the whole gesture, had no fixed distance to travel. Two numbers you have to maintain are the right trade here: the pill is a control with a resting size, not a box that hugs whatever is in it.

The fill and the ring transition together over 200ms. Only the shadow was transitioned before, so the background snapped to white while the ring eased in, and the two arriving at different times read as a hard edge at the end of an otherwise smooth move.

The label is min-w-0 truncate. Without it a flex child defaults to min-width: auto and refuses to shrink below its own text, so a name too long for the pill does not clip itself — it pushes the button out through the side. That is a structural guarantee rather than a matter of picking generous widths: the two defaults suit a ~15-character name, and anything longer ellipsizes with the button still inside.

Icons animate transform, opacity and filter only. Blur is capped at 6px on 40px buttons, so paint stays negligible.

Source

The whole component. In a copy-paste library the source is the delivery mechanism, not an appendix — so it is read straight off disk at build time and cannot drift from what ships.

EditableLabel.tsx
"use client";

import { useCallback, useEffect, useId, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, Pencil } from "lucide-react";

import { cn } from "@/lib/cn";
import { blurTransition, duration, ease, spring } from "@/lib/motion";

/* -------------------------------------------------------------------------- */
/* Types                                                                       */
/* -------------------------------------------------------------------------- */

export interface EditableLabelProps extends Omit<
  React.ComponentPropsWithoutRef<"div">,
  "onChange" | "defaultValue"
> {
  /** Uncontrolled initial text. */
  defaultValue?: string;
  /** Controlled text. Pass with `onValueChange`. */
  value?: string;
  /** Fires on commit, never on keystroke — this is a rename, not a field. */
  onValueChange?: (value: string) => void;
  /** Shown when the value is empty. @default "Untitled" */
  placeholder?: string;
  /** Names what is being renamed, for assistive technology. @default "Name" */
  fieldLabel?: string;
  /** Rejects a draft before it commits. Return false to keep editing. */
  validate?: (draft: string) => boolean;
  disabled?: boolean;
  /**
   * Resting width in px. Sized for a ~15-character name; widen it for longer
   * ones rather than letting them truncate. @default 220
   */
  restingWidth?: number;
  /** Width while editing, in px. @default 290 */
  editingWidth?: number;
}

/* -------------------------------------------------------------------------- */
/* Motion                                                                      */
/* -------------------------------------------------------------------------- */

const iconVariants = {
  initial: { scale: 0, opacity: 0, filter: "blur(6px)" },
  animate: { scale: 1, opacity: 1, filter: "blur(0px)" },
  exit: { scale: 0, opacity: 0, filter: "blur(6px)" },
} as const;

const reducedIconVariants = {
  initial: { opacity: 0 },
  animate: { opacity: 1 },
  exit: { opacity: 0 },
} as const;

/* -------------------------------------------------------------------------- */
/* Component                                                                   */
/* -------------------------------------------------------------------------- */

/**
 * Rename in place.
 *
 * Renaming usually means leaving for a settings screen or a dialog, which puts
 * the thing being renamed out of sight at exactly the moment you are deciding
 * what to call it. Editing where the name already sits keeps the context — and
 * the surrounding layout — visible throughout.
 */
export function EditableLabel({
  defaultValue = "",
  value: controlledValue,
  onValueChange,
  placeholder = "Untitled",
  fieldLabel = "Name",
  validate,
  disabled = false,
  restingWidth = 220,
  editingWidth = 290,
  className,
  ...rest
}: EditableLabelProps) {
  const reduce = useReducedMotion();
  const uid = useId();
  const inputId = `${uid}-input`;

  const [uncontrolled, setUncontrolled] = useState(defaultValue);
  const isControlled = controlledValue !== undefined;
  const value = isControlled ? controlledValue : uncontrolled;

  const [editing, setEditing] = useState(false);
  const [draft, setDraft] = useState(value);
  const inputRef = useRef<HTMLInputElement>(null);
  const editButtonRef = useRef<HTMLButtonElement>(null);

  // Select-all on entry: renaming usually replaces the name rather than
  // appending to it, so the common case should take one keystroke.
  useEffect(() => {
    if (!editing) return;
    inputRef.current?.focus();
    inputRef.current?.select();
  }, [editing]);

  const startEditing = useCallback(() => {
    if (disabled) return;
    setDraft(value);
    setEditing(true);
  }, [disabled, value]);

  const commit = useCallback(() => {
    const next = draft.trim();
    // An empty rename is a mistake, not an instruction. Keep the old value.
    if (next === "" || validate?.(next) === false) {
      setEditing(false);
      editButtonRef.current?.focus();
      return;
    }
    if (!isControlled) setUncontrolled(next);
    onValueChange?.(next);
    setEditing(false);
    editButtonRef.current?.focus();
  }, [draft, validate, isControlled, onValueChange]);

  const cancel = useCallback(() => {
    setDraft(value);
    setEditing(false);
    editButtonRef.current?.focus();
  }, [value]);

  const handleKeyDown = useCallback(
    (event: React.KeyboardEvent<HTMLInputElement>) => {
      if (event.key === "Enter") {
        event.preventDefault();
        commit();
      }
      if (event.key === "Escape") {
        event.preventDefault();
        cancel();
      }
    },
    [commit, cancel],
  );

  const transition = reduce ? { duration: 0.01 } : spring.snappy;
  const swap = reduce
    ? { duration: 0.01 }
    : { duration: duration.micro, ease: ease.out };

  return (
    <div
      className={cn("flex items-center justify-center", className)}
      {...rest}
    >
      {/*
        An explicit width pair, not `layout`. Sizing the pill to its content
        looked tidier and was wrong: the field has no intrinsic width, so
        entering edit mode collapsed the pill and then grew it a character at a
        time as you typed. The two widths are what give the resize a fixed
        distance to travel, which is the whole gesture.
      */}
      <motion.div
        initial={false}
        animate={{ width: editing ? editingWidth : restingWidth }}
        transition={transition}
        className={cn(
          "flex items-center gap-2 overflow-hidden rounded-full p-2",
          // Background *and* shadow. Only the shadow was transitioned before,
          // so the fill snapped while the ring eased — the two arriving apart
          // is what read as a hard edge at the end of the move.
          "transition-[background-color,box-shadow] duration-200 ease-out",
          editing
            ? "bg-surface shadow-[0_0_0_2.5px_var(--ring)]"
            : "bg-surface-subtle shadow-resting",
        )}
      >
        <AnimatePresence mode="wait" initial={false}>
          {editing ? (
            <motion.input
              key="input"
              ref={inputRef}
              id={inputId}
              aria-label={fieldLabel}
              value={draft}
              onChange={(event) => setDraft(event.target.value)}
              onKeyDown={handleKeyDown}
              onBlur={commit}
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              transition={swap}
              className="ml-3 min-w-0 flex-1 bg-transparent text-lg font-semibold text-content outline-none"
            />
          ) : (
            <motion.span
              key="label"
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              transition={swap}
              // `min-w-0 truncate`, not just `whitespace-nowrap`. A flex item
              // defaults to `min-width: auto` and will not shrink below its own
              // text, so a long name does not clip itself — it shoves the
              // button out through the side of the pill. Truncating keeps the
              // button inside at any width.
              className={cn(
                "ml-3 min-w-0 flex-1 truncate text-lg font-semibold select-none",
                value ? "text-content" : "text-content-subtle",
              )}
            >
              {value || placeholder}
            </motion.span>
          )}
        </AnimatePresence>

        <AnimatePresence mode="popLayout" initial={false}>
          {editing ? (
            <motion.button
              key="confirm"
              type="button"
              // onMouseDown rather than onClick: the input's onBlur fires
              // first and would commit and unmount this button before the
              // click completed, so the press would land on nothing.
              onMouseDown={(event) => event.preventDefault()}
              onClick={commit}
              aria-label={`Save ${fieldLabel.toLowerCase()}`}
              variants={reduce ? reducedIconVariants : iconVariants}
              initial="initial"
              animate="animate"
              exit="exit"
              transition={{ ...transition, filter: blurTransition }}
              className="focus-ring relative grid size-10 shrink-0 cursor-pointer place-items-center rounded-full bg-accent text-accent-content before:absolute before:-inset-0.5 before:content-['']"
            >
              <Check className="size-4" strokeWidth={2.5} aria-hidden="true" />
            </motion.button>
          ) : (
            <motion.button
              key="edit"
              ref={editButtonRef}
              type="button"
              onClick={startEditing}
              disabled={disabled}
              aria-label={`Edit ${fieldLabel.toLowerCase()}`}
              variants={reduce ? reducedIconVariants : iconVariants}
              initial="initial"
              animate="animate"
              exit="exit"
              transition={{ ...transition, filter: blurTransition }}
              className="focus-ring relative grid size-10 shrink-0 cursor-pointer place-items-center rounded-full bg-surface text-content-subtle before:absolute before:-inset-0.5 before:content-[''] hover:text-content disabled:cursor-not-allowed disabled:opacity-50"
            >
              <Pencil className="size-4" strokeWidth={2} aria-hidden="true" />
            </motion.button>
          )}
        </AnimatePresence>
      </motion.div>

      <span role="status" aria-live="polite" className="sr-only">
        {editing ? `Editing ${fieldLabel.toLowerCase()}` : ""}
      </span>
    </div>
  );
}

export default EditableLabel;

Technologies

FrameworkReact 19
MotionMotion 12 (motion/react)
StylingTailwind CSS v4
Iconslucide-react
TypesTypeScript 5.9, strict

Credits

Original interaction by @nitishkmrk. Rebuilt with a props API, controlled and uncontrolled modes, focus restoration, Escape to cancel, and reduced-motion handling. The 190/260 width pair and the spring's character are the original's.