Inline Edit Swap
A value becomes a field where it sits: the surface arrives around the words and the row never changes size.
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.
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.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Document page
Property values open for editing without the row moving.
Related patterns
- Search Input ExpandA search icon opens into a full field while the controls beside it give up the space.
- Select Dropdown OpenThe list unfolds under the field, options a beat behind the panel, current choice already marked.
- Password Reveal ToggleDots hand over to the real characters one cell at a time as the eye takes its slash.