Delete Confirm Morph
A delete control widens in place into a question with a way out, instead of opening a dialog.
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, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Delete Confirm Morph
*
* A destructive button that asks in place: the control widens out of
* itself into "Delete this report?" with a way back, then narrows into
* a confirmed state. No dialog, no overlay, no lost context.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Works with zero props; tune via `variant`, `label`, `question`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type DeleteConfirmMorphProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Resting label of the destructive control. */
label?: string;
/** What the widened state asks. */
question?: string;
/** Label of the confirming control. */
confirmLabel?: string;
/** Label of the way out. */
cancelLabel?: string;
/** Label of the settled state. */
doneLabel?: string;
/** How long the settled state is held before returning to rest, in ms. */
resetMs?: number;
/** Fires when the destructive action is confirmed. */
onConfirm?: () => void;
/** Fires when the user backs out. */
onCancel?: () => void;
};
type Phase = "idle" | "asking" | "done";
type VariantConfig = {
/** The width change itself. One soft settle at most. */
spring: { type: "spring"; stiffness: number; damping: number };
/** Crossfade between the contents of the two widths. */
swapDuration: number;
};
// The shell changes size while words sit inside it, so the spring has to
// land: every ratio here is at or above 0.8. Variants differ in how
// briskly the width opens, never in how many times it settles.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Opens almost instantly. For dense rows full of destructive controls.
subtle: {
spring: { type: "spring", stiffness: 720, damping: 54 },
swapDuration: 0.07,
},
default: {
spring: { type: "spring", stiffness: 420, damping: 38 },
swapDuration: 0.15,
},
// A slower, wider opening — the question arrives with more presence.
playful: {
spring: { type: "spring", stiffness: 250, damping: 26 },
swapDuration: 0.23,
},
};
const DANGER = "#E5484D";
const DONE = "#10B981";
/** Theme-adaptive neutral: mixing the inherited text color with
* `transparent` lands correctly on a light surface and on a dark one. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const SHELL_RADIUS = 10;
const ghostButton = {
height: 28,
padding: "0 10px",
fontSize: 12.5,
fontWeight: 600,
fontFamily: "inherit",
border: 0,
borderRadius: 7,
cursor: "pointer",
whiteSpace: "nowrap" as const,
};
export default function DeleteConfirmMorph({
variant = "default",
label = "Delete",
question = "Delete this report?",
confirmLabel = "Delete",
cancelLabel = "Keep",
doneLabel = "Deleted",
resetMs = 1900,
onConfirm,
onCancel,
}: DeleteConfirmMorphProps) {
const [phase, setPhase] = useState<Phase>("idle");
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
// The callback lives in a ref so an inline arrow from the parent can't
// re-trigger the effect and restart the reset timer.
const onConfirmRef = useRef(onConfirm);
useEffect(() => {
onConfirmRef.current = onConfirm;
}, [onConfirm]);
useEffect(() => {
if (phase !== "done") return;
const timer = setTimeout(() => setPhase("idle"), resetMs);
return () => clearTimeout(timer);
}, [phase, resetMs]);
// Reduced motion: the same three states, reached by crossfade. The
// width still changes — that is the layout, not the animation — it
// simply changes at once instead of travelling.
const swap = { duration: cfg.swapDuration, ease: "easeOut" } as const;
return (
<motion.div
// Only the shell animates its box. Everything inside is marked
// `layout="position"`, so the children travel to their new spot
// without inheriting the shell's horizontal stretch — which is
// what would otherwise smear the words as the width opens.
layout={reduceMotion ? false : true}
transition={reduceMotion ? { duration: 0 } : cfg.spring}
style={{
position: "relative",
display: "inline-flex",
alignItems: "center",
gap: 8,
height: 36,
padding: "0 6px",
borderRadius: SHELL_RADIUS,
background: tone(6),
border: `1px solid ${tone(12)}`,
color: "inherit",
}}
>
{/* The shell "changes color" by fading tints over it rather than
tweening a background: color-mix values cannot be interpolated,
and an opacity crossfade stays on the compositor. */}
<motion.span
aria-hidden
initial={false}
animate={{ opacity: phase === "asking" ? 1 : 0 }}
transition={swap}
style={{
position: "absolute",
inset: 0,
borderRadius: SHELL_RADIUS,
background: `color-mix(in srgb, ${DANGER} 10%, transparent)`,
pointerEvents: "none",
}}
/>
<motion.span
aria-hidden
initial={false}
animate={{ opacity: phase === "done" ? 1 : 0 }}
transition={swap}
style={{
position: "absolute",
inset: 0,
borderRadius: SHELL_RADIUS,
background: `color-mix(in srgb, ${DONE} 12%, transparent)`,
pointerEvents: "none",
}}
/>
{/* popLayout takes the outgoing content out of flow immediately, so
the shell starts resizing on the same frame the swap begins. */}
<AnimatePresence mode="popLayout" initial={false}>
{phase === "idle" && (
<motion.button
key="idle"
type="button"
layout="position"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={swap}
onClick={() => setPhase("asking")}
style={{
...ghostButton,
display: "inline-flex",
alignItems: "center",
gap: 7,
color: DANGER,
background: "transparent",
}}
>
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden>
<path
d="M3 4.5h10M6.4 4.5V3.2h3.2v1.3M4.4 4.5l.6 8.1h6l.6-8.1"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
{label}
</motion.button>
)}
{phase === "asking" && (
<motion.div
key="asking"
layout="position"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={swap}
style={{ display: "inline-flex", alignItems: "center", gap: 8 }}
>
<span
style={{
fontSize: 12.5,
fontWeight: 500,
paddingLeft: 6,
whiteSpace: "nowrap",
}}
>
{question}
</span>
<button
type="button"
onClick={() => {
setPhase("idle");
onCancel?.();
}}
style={{ ...ghostButton, color: "inherit", background: tone(10) }}
>
{cancelLabel}
</button>
<button
type="button"
onClick={() => {
setPhase("done");
onConfirmRef.current?.();
}}
style={{ ...ghostButton, color: "#FFFFFF", background: DANGER }}
>
{confirmLabel}
</button>
</motion.div>
)}
{phase === "done" && (
<motion.div
key="done"
layout="position"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={swap}
role="status"
aria-live="polite"
style={{
display: "inline-flex",
alignItems: "center",
gap: 7,
padding: "0 8px",
fontSize: 12.5,
fontWeight: 600,
color: DONE,
whiteSpace: "nowrap",
}}
>
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden>
<motion.path
d="M3.4 8.4 6.3 11.3 12.6 5"
stroke={DONE}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
initial={{ pathLength: reduceMotion ? 1 : 0 }}
animate={{ pathLength: 1 }}
transition={
reduceMotion ? { duration: 0 } : { duration: 0.24, ease: "easeOut" }
}
/>
</svg>
{doneLabel}
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
}About this pattern
A confirmation that keeps the user where they were. The control opens out of itself into a question and a pair of answers, then narrows into a settled state — the row it belongs to never leaves the screen, so there is no context to rebuild afterwards. Only the shell animates its box; every child is marked position-only, which is what stops the words from smearing sideways as the width opens. The shell changes color by fading tints over it rather than tweening a background, because mixed colors cannot be interpolated and an opacity crossfade stays on the compositor. The way out is always the closer of the two answers.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Inbox
A confirming state opens out of the first control instead of replacing the screen.
Related patterns
- Destructive Hold to ConfirmHolding fills the control at an honest rate; letting go early drains it back several times faster.
- Form Submit ProgressSubmit narrows to a turning disc while the request runs, then opens out as the confirmed state.
- Error Retry NudgeA failed action answers with one short damped nudge and becomes its own retry.