All patterns

Inline Edit Swap

A value becomes a field where it sits: the surface arrives around the words and the row never changes size.

formselegantminimalinteraction · finite · intermediate · ~0.2s
Interactive · click to play
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

430 lines · react + motion only
import {
  useEffect,
  useId,
  useRef,
  useState,
  type CSSProperties,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Inline Edit Swap
 *
 * A value becomes an editable field exactly where it sits. The text and
 * the input share one box at one type size, so the swap is a crossfade
 * and nothing on the row moves — the field surface simply arrives around
 * the words that were already there.
 *
 * Self-contained: depends only on `react` and `motion`. Surfaces are mixed
 * from the inherited text color, so the row reads correctly on a light
 * page and on a dark one.
 * Works with zero props; tune via `variant`, `label`, `defaultValue`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type InlineEditSwapProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Row label, also the accessible name of the field. */
  label?: string;
  /** Starting value. */
  defaultValue?: string;
  /** Native input type. */
  type?: string;
  /** Accent for the open field and focus. */
  accent?: string;
  /** Overall width. */
  width?: number | string;
  /** Fires with the committed value. */
  onCommit?: (value: string) => void;
};

type VariantConfig = {
  /** Seconds for the text and input to cross. */
  swap: number;
  /** Seconds for the field surface to arrive. */
  surface: number;
  /** How far the confirm controls slide in, in px. */
  slide: number;
  action: { type: "spring"; stiffness: number; damping: number };
};

// Quality rule: the row must not resize. The reading state and the edit
// state share a box, a font size and a padding, so the swap is opacity
// only — a size change here would push the rest of the settings list
// around every time someone clicked a pencil. The action spring is on
// icons, not text, and sits above a 0.8 damping ratio.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // A clean cut, near instant. For a dense settings list.
  subtle: {
    swap: 0.08,
    surface: 0.11,
    slide: 3,
    action: { type: "spring", stiffness: 690, damping: 52 },
  },
  // The surface is visibly drawn around the value. All-purpose.
  default: {
    swap: 0.14,
    surface: 0.2,
    slide: 6,
    action: { type: "spring", stiffness: 540, damping: 43 },
  },
  // A longer cross for a profile page with only a few editable rows.
  playful: {
    swap: 0.2,
    surface: 0.29,
    slide: 10,
    action: { type: "spring", stiffness: 400, damping: 34 },
  },
};

/** Theme-adaptive neutral: `currentColor` is the text color this component
 *  inherits — near-black on a light page, near-white on a dark one — so
 *  mixing it with `transparent` yields a surface, border or fill that is
 *  correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

/** Shared metrics. Both states are laid out from these, which is what
 *  keeps the row exactly the same size in either one. */
const FIELD_HEIGHT = 34;
const FIELD_PADDING = "0 10px";
const FIELD_FONT = 13;
const ACTIONS_WIDTH = 62;

function iconButtonStyle(
  focused: boolean,
  accent: string,
  emphasis: boolean
): CSSProperties {
  return {
    display: "grid",
    placeItems: "center",
    width: 26,
    height: 26,
    flex: "0 0 auto",
    padding: 0,
    borderRadius: 8,
    border: `1px solid ${emphasis ? "transparent" : tone(12)}`,
    background: emphasis ? accent : tone(6),
    color: emphasis ? "#FFFFFF" : "inherit",
    cursor: "pointer",
    outline: "none",
    boxShadow: focused
      ? `0 0 0 2px color-mix(in srgb, ${accent} 55%, transparent)`
      : "0 0 0 0 transparent",
    transition: "box-shadow 140ms ease-out, background-color 140ms ease-out",
  };
}

export default function InlineEditSwap({
  variant = "default",
  label = "Display name",
  defaultValue = "Meridian Labs",
  type = "text",
  accent = "#5B5BD6",
  width = 320,
  onCommit,
}: InlineEditSwapProps) {
  const [value, setValue] = useState(defaultValue);
  const [draft, setDraft] = useState(defaultValue);
  const [editing, setEditing] = useState(false);
  const [focusedControl, setFocusedControl] = useState<string | null>(null);
  const inputRef = useRef<HTMLInputElement>(null);
  const editRef = useRef<HTMLButtonElement>(null);
  const fieldId = useId();
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  // The caret follows the swap, and closing hands focus back to the
  // control that opened it — otherwise the tab position is lost every
  // time a row is edited.
  useEffect(() => {
    if (editing) inputRef.current?.focus();
  }, [editing]);

  const open = () => {
    setDraft(value);
    setEditing(true);
  };

  const close = () => {
    setEditing(false);
    requestAnimationFrame(() => editRef.current?.focus());
  };

  const save = () => {
    setValue(draft.trim().length > 0 ? draft.trim() : value);
    onCommit?.(draft.trim());
    close();
  };

  const fade = { duration: reduceMotion ? 0 : cfg.swap, ease: "easeOut" as const };

  return (
    <div style={{ width, color: "inherit" }}>
      {/* A plain caption, not a `label` element: the reading state and the
          edit state swap places, so there is no single control to bind to.
          The input and the reading button each carry the name themselves. */}
      <span
        style={{
          display: "block",
          marginBottom: 5,
          fontSize: 11,
          fontWeight: 650,
          letterSpacing: 0.3,
          opacity: 0.5,
        }}
      >
        {label.toUpperCase()}
      </span>

      <div
        style={{
          display: "flex",
          alignItems: "center",
          gap: 8,
          height: FIELD_HEIGHT,
        }}
      >
        <div
          style={{ position: "relative", flex: 1, minWidth: 0, height: "100%" }}
        >
          {/* The field surface. It fades in around the value rather than
              pushing it: the text is already sitting where the input will
              put it. */}
          <motion.span
            aria-hidden
            initial={false}
            animate={{ opacity: editing ? 1 : 0 }}
            transition={{
              duration: reduceMotion ? 0 : cfg.surface,
              ease: "easeOut",
            }}
            style={{
              position: "absolute",
              inset: 0,
              borderRadius: 9,
              border: `1px solid ${accent}`,
              background: tone(6),
              boxShadow: `0 0 0 3px color-mix(in srgb, ${accent} 20%, transparent)`,
              pointerEvents: "none",
            }}
          />

          <AnimatePresence initial={false}>
            {editing ? (
              <motion.input
                key="input"
                ref={inputRef}
                id={fieldId}
                type={type}
                value={draft}
                aria-label={label}
                onChange={(event) => setDraft(event.target.value)}
                onKeyDown={(event) => {
                  if (event.key === "Enter") {
                    event.preventDefault();
                    save();
                  } else if (event.key === "Escape") {
                    event.preventDefault();
                    close();
                  }
                }}
                initial={{ opacity: 0 }}
                animate={{ opacity: 1 }}
                exit={{ opacity: 0 }}
                transition={fade}
                style={{
                  position: "absolute",
                  inset: 0,
                  width: "100%",
                  height: "100%",
                  boxSizing: "border-box",
                  padding: FIELD_PADDING,
                  border: "none",
                  borderRadius: 9,
                  background: "transparent",
                  color: "inherit",
                  fontFamily: "inherit",
                  // Identical to the reading state, deliberately: the swap
                  // is a crossfade, so any difference here would show up
                  // as the text jumping.
                  fontSize: FIELD_FONT,
                  fontWeight: 550,
                  outline: "none",
                }}
              />
            ) : (
              <motion.button
                key="text"
                type="button"
                onClick={open}
                onFocus={() => setFocusedControl("text")}
                onBlur={() => setFocusedControl(null)}
                aria-label={`Edit ${label}`}
                initial={{ opacity: 0 }}
                animate={{ opacity: 1 }}
                exit={{ opacity: 0 }}
                transition={fade}
                style={{
                  position: "absolute",
                  inset: 0,
                  display: "flex",
                  alignItems: "center",
                  width: "100%",
                  boxSizing: "border-box",
                  padding: FIELD_PADDING,
                  borderRadius: 9,
                  border: "1px solid transparent",
                  background:
                    focusedControl === "text" ? tone(6) : "transparent",
                  color: "inherit",
                  fontFamily: "inherit",
                  fontSize: FIELD_FONT,
                  fontWeight: 550,
                  textAlign: "left",
                  cursor: "text",
                  outline: "none",
                  boxShadow:
                    focusedControl === "text"
                      ? `0 0 0 2px color-mix(in srgb, ${accent} 45%, transparent)`
                      : "0 0 0 0 transparent",
                  transition:
                    "background-color 140ms ease-out, box-shadow 140ms ease-out",
                }}
              >
                <span
                  style={{
                    overflow: "hidden",
                    textOverflow: "ellipsis",
                    whiteSpace: "nowrap",
                  }}
                >
                  {value}
                </span>
              </motion.button>
            )}
          </AnimatePresence>
        </div>

        {/* A fixed slot for the controls. The pencil and the confirm pair
            occupy the same width, so swapping them cannot reflow the row. */}
        <div
          style={{
            display: "flex",
            justifyContent: "flex-end",
            gap: 6,
            width: ACTIONS_WIDTH,
            flex: "0 0 auto",
          }}
        >
          <AnimatePresence initial={false} mode="popLayout">
            {editing ? (
              [
                { id: "cancel", emphasis: false },
                { id: "save", emphasis: true },
              ].map((action, index) => (
                <motion.button
                  key={action.id}
                  type="button"
                  aria-label={
                    action.id === "save" ? `Save ${label}` : `Cancel editing ${label}`
                  }
                  onClick={action.id === "save" ? save : close}
                  onFocus={() => setFocusedControl(action.id)}
                  onBlur={() => setFocusedControl(null)}
                  initial={
                    reduceMotion
                      ? { opacity: 0 }
                      : { opacity: 0, x: cfg.slide }
                  }
                  animate={{ opacity: 1, x: 0 }}
                  exit={{ opacity: 0 }}
                  transition={
                    reduceMotion
                      ? { duration: 0.1 }
                      : {
                          ...cfg.action,
                          delay: index * 0.03,
                          opacity: { duration: cfg.swap },
                        }
                  }
                  style={iconButtonStyle(
                    focusedControl === action.id,
                    accent,
                    action.emphasis
                  )}
                >
                  {action.id === "save" ? (
                    <svg
                      width="12"
                      height="12"
                      viewBox="0 0 20 20"
                      fill="none"
                      stroke="currentColor"
                      strokeWidth="2.6"
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      aria-hidden
                    >
                      <path d="m4.5 10.5 3.8 3.8L15.5 6" />
                    </svg>
                  ) : (
                    <svg
                      width="11"
                      height="11"
                      viewBox="0 0 20 20"
                      fill="none"
                      stroke="currentColor"
                      strokeWidth="2.4"
                      strokeLinecap="round"
                      aria-hidden
                    >
                      <path d="M5 5l10 10M15 5 5 15" />
                    </svg>
                  )}
                </motion.button>
              ))
            ) : (
              <motion.button
                key="edit"
                ref={editRef}
                type="button"
                aria-label={`Edit ${label}`}
                onClick={open}
                onFocus={() => setFocusedControl("edit")}
                onBlur={() => setFocusedControl(null)}
                initial={
                  reduceMotion ? { opacity: 0 } : { opacity: 0, x: -cfg.slide }
                }
                animate={{ opacity: 1, x: 0 }}
                exit={{ opacity: 0 }}
                transition={
                  reduceMotion
                    ? { duration: 0.1 }
                    : { ...cfg.action, opacity: { duration: cfg.swap } }
                }
                style={iconButtonStyle(focusedControl === "edit", accent, false)}
              >
                <svg
                  width="12"
                  height="12"
                  viewBox="0 0 20 20"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth="1.9"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  aria-hidden
                >
                  <path d="M13.4 3.6a1.7 1.7 0 0 1 2.4 2.4L7.4 14.4 4 15l.6-3.4z" />
                </svg>
              </motion.button>
            )}
          </AnimatePresence>
        </div>
      </div>
    </div>
  );
}

About this pattern

The failure mode of edit-in-place is the row growing when it opens, which shoves the rest of the settings list down and makes a small correction feel like a page change. So the reading state and the edit state are built from the same box, the same padding and the same type size, and the swap between them is opacity only — the field surface fades in around words that were already sitting exactly where the input will put them. The confirm controls take the same fixed slot the pencil occupied, so even the right edge holds still. The caret follows the swap in, escape cancels, enter commits, and focus lands back on the control that opened the row.

Settings row editProfile detail changeRename in placeRecord detail corrections

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Ridgeline
    Docs
    Recent
    Shared
    Templates
    Trash
    DocsNew
    Q3 planning notesEdited 14 minutes agoScope
    Document page

    Property values open for editing without the row moving.

Related patterns