본문으로 건너뛰기
컴포넌트

확인 변형 버튼

누르면 그 자리에서 확인, 진행, 완료 단계로 모양이 바뀌는 버튼이에요.

  • #arc
  • #component
  • #confirm
  • #button
  • #destructive
  • #animation

설치

이 명령 한 줄이면 필요한 파일과 패키지가 함께 설치돼요.

터미널bash
npx shadcn@latest add https://dsui.vercel.app/r/confirm-morph.json

confirm-morph.module.csscss
/* The root holds the control in the flow of the page; the pill inside it springs between faces. */
.root {
  position: relative;
  display: inline-flex;
  min-width: 0;
  max-width: 100%;
  vertical-align: middle;
  color: var(--foreground);
  font-family: var(--font-body);
  font-size: var(--text-sm);
  letter-spacing: var(--tracking-body);
  line-height: 20px;
  -webkit-tap-highlight-color: transparent;
}

/*
 * One neutral pill for every state. Only its width moves; corners, text and border never scale, so nothing distorts.
 * Every face sits at its natural width against the pill's leading edge, so text never slides or squeezes: the question
 * starts where the label was, and the springing edge reveals the rest. The border is an overlay so it never changes a measurement.
 */
.surface {
  position: relative;
  display: flex;
  height: var(--control-height-sm);
  max-width: 100%;
  justify-content: flex-start;
  overflow: clip;
  border-radius: var(--radius-pill);
  background: var(--surface-raised);
  box-shadow: var(--shadow-resting);
  transition: background-color var(--duration-standard) var(--ease-standard), box-shadow var(--duration-standard) var(--ease-standard);
}
.surface::after { content: ""; position: absolute; inset: 0; border: 1px solid var(--border); border-radius: inherit; pointer-events: none; transition: border-color var(--duration-standard) var(--ease-standard); }
.root[data-disabled][data-state="idle"] .surface { box-shadow: none; }
.root[data-disabled][data-state="idle"] .surface::after { border-color: var(--border-subtle); }

.face { display: flex; flex: none; height: 100%; align-items: center; gap: 2px; padding: 0 4px; white-space: nowrap; }
.face[data-face="idle"] { padding: 0; }
/* A leaving face steps out of the flow, exactly where it was, and fades while the pill springs to the next one. */
.face[inert] { position: absolute; top: 0; inset-inline-start: 0; pointer-events: none; }
.part { display: inline-flex; height: 100%; align-items: center; }

.trigger {
  display: inline-flex;
  height: 100%;
  align-items: center;
  gap: 8px;
  padding: 0 16px 0 14px;
  border: 0;
  border-radius: var(--radius-pill);
  background: transparent;
  color: var(--foreground);
  cursor: pointer;
  font: inherit;
  font-weight: 500;
  transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard);
}
.trigger:active:not(:disabled) { background: color-mix(in oklab, var(--foreground) 9%, transparent); transition-duration: 0ms; }
.trigger:disabled { color: var(--text-muted); cursor: not-allowed; }
.trigger:disabled .icon { opacity: .7; }
.icon { display: grid; width: 16px; height: 16px; place-items: center; color: var(--text-secondary); }
.trigger:disabled .icon { color: inherit; }
.icon > svg { width: 16px; height: 16px; }

.prompt { max-width: 16rem; overflow: hidden; padding: 0 8px 0 12px; color: var(--foreground); font-weight: 500; font-variant-numeric: tabular-nums; }
.prompt > span { overflow: hidden; text-overflow: ellipsis; }

.secondary,
.confirm {
  position: relative;
  display: inline-flex;
  flex: none;
  height: calc(var(--control-height-sm) - 8px);
  align-items: center;
  justify-content: center;
  padding: 0 12px;
  border: 0;
  border-radius: var(--radius-pill);
  cursor: pointer;
  font: inherit;
  font-weight: 500;
  transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard);
  touch-action: manipulation;
}
.secondary { background: transparent; color: var(--text-secondary); }
.face:is([data-face="done"], [data-face="error"]) .secondary { color: var(--foreground); }
.secondary:active { background: color-mix(in oklab, var(--foreground) 11%, transparent); color: var(--foreground); transition-duration: 0ms; }

/* The only coloured thing in the control: danger for a destructive answer, the accent for a reversible one. */
.confirm { overflow: clip; background: color-mix(in oklab, var(--danger) 13%, transparent); color: var(--danger); }
.confirm:active { background: color-mix(in oklab, var(--danger) 22%, transparent); transition-duration: 0ms; }
.root[data-tone="neutral"] .confirm { background: var(--accent); color: var(--accent-foreground); }
.root[data-tone="neutral"] .confirm:active { background: var(--accent-strong); }

/* Hold: a solid copy of the button, label and all, revealed from the left as the hold fills; the label inverts as the edge passes. */
.root[data-mode="hold"] .confirm { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
.fill { position: absolute; inset: 0; display: inline-flex; align-items: center; justify-content: center; background: var(--danger); color: var(--background); pointer-events: none; }
.root[data-tone="neutral"] .fill { background: var(--accent-strong); color: var(--accent-foreground); }

.status { gap: 8px; padding: 0 10px 0 12px; color: var(--foreground); font-weight: 500; font-variant-numeric: tabular-nums; }
.status > svg { flex: none; }
.statusText { display: inline-flex; align-items: baseline; gap: .25em; }
.face[data-face="pending"] .status { padding-inline: 12px 16px; color: var(--text-secondary); }
.face[data-face="done"]:not(:has(.secondary)) .status { padding-right: 14px; }
.face[data-face="counting"] .status { padding-right: 6px; color: var(--text-secondary); }
.alert { color: var(--danger); }
.check { width: 18px; height: 18px; }
.checkDisc { fill: var(--success); }
.checkTick { stroke: var(--background); }
.spinner { animation: spin .7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }

.ring { width: 16px; height: 16px; }
.ringTrack { stroke: var(--border); }
.ringFill { stroke: var(--danger); }
.root[data-tone="neutral"] .ringFill { stroke: var(--accent); }
/* Seconds keep one tabular width while the digits swap. */
.seconds { display: inline-grid; min-width: 1ch; color: var(--foreground); font-variant-numeric: tabular-nums; }
.digit { grid-area: 1 / 1; }

/* No focus rings anywhere in the control; keyboard focus reads through fill (below). */
.root, .root :focus { outline: none; }

.srOnly { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }

@media (hover: hover) and (pointer: fine) {
  .trigger:hover:not(:disabled):not(:active) { background: color-mix(in oklab, var(--foreground) 5%, transparent); }
  .secondary:hover:not(:active) { background: color-mix(in oklab, var(--foreground) 7%, transparent); color: var(--foreground); }
  .confirm:hover:not(:active) { background: color-mix(in oklab, var(--danger) 18%, transparent); }
  .root[data-tone="neutral"] .confirm:hover:not(:active) { background: var(--accent-strong); }
}
/* Keyboard focus reads through fill, never a ring. */
.trigger:focus-visible { background: color-mix(in oklab, var(--foreground) 6%, transparent); }
.secondary:focus-visible { background: color-mix(in oklab, var(--foreground) 8%, transparent); color: var(--foreground); }
.confirm:focus-visible { background: color-mix(in oklab, var(--danger) 20%, transparent); }
.root[data-tone="neutral"] .confirm:focus-visible { background: var(--accent-strong); }

@media (prefers-reduced-motion: reduce) {
  .surface, .surface::after, .trigger, .secondary, .confirm { transition-duration: 0ms; }
  .spinner { animation-duration: 1.6s; }
}

@media (prefers-contrast: more) {
  .surface::after { border-color: var(--border-strong); }
  .confirm { background: color-mix(in oklab, var(--danger) 22%, transparent); }
}
confirm-morph.tsxtsx
"use client";

import { useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
import type { KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent, ReactNode, Ref } from "react";
import { AnimatePresence, animate, motion, useIsPresent, useMotionValue, useMotionValueEvent, useTransform } from "motion/react";
import { useReducedMotion } from "../lib/use-reduced-motion";
import type { AnimationPlaybackControls, MotionValue, Transition, Variants } from "motion/react";
import { motionTokens } from "../lib/motion-tokens";
import styles from "./confirm-morph.module.css";
import { UiIcon } from "../theme/ui-icon";
import { playUiSound } from "../sound/play";

/** Where the control is in its life: resting, asking, working, finished, or failed. */
export type ConfirmMorphState = "idle" | "confirming" | "pending" | "done" | "error";

/** How the question is answered: a press, a press held until the fill completes, or a press followed by a short countdown. */
export type ConfirmMorphMode = "press" | "hold" | "countdown";

/**
 * A button for destructive or important actions that asks in place. Pressing it morphs the same pill into an inline question
 * with Cancel and Confirm, then into a spinner, then into a result with Undo. The width springs to each face while the faces
 * crossfade at their natural width, so text never squashes and nothing around the control jumps. Escape, an outside press,
 * or the timeout all return it to rest.
 * Use it where a modal dialog would be heavy: deleting a project, revoking access, discarding a draft.
 */
export interface ConfirmMorphProps {
  /** The resting label, 예: "프로젝트 삭제하기". */
  label: ReactNode;
  /** A plain icon before the resting label. */
  icon?: ReactNode;
  /** The question shown while confirming, 예: "파일 3개를 삭제할까요?". 기본값은 라벨 뒤에 물음표를 붙인 문구예요. */
  prompt?: ReactNode;
  confirmLabel?: string;
  cancelLabel?: string;
  /** Shown beside the spinner while `onConfirm` resolves. */
  pendingLabel?: string;
  doneLabel?: string;
  errorLabel?: string;
  retryLabel?: string;
  undoLabel?: string;
  /** Shown beside the spinner while `onUndo` resolves. */
  undoingLabel?: string;
  /**
   * `danger` (default) gives the confirm button the danger colour, for actions that destroy something. `neutral` gives it the
   * accent, for important actions that can be reversed. The resting button is neutral either way.
   */
  tone?: "danger" | "neutral";
  /** `press` confirms on a press. `hold` asks for a press held for `holdDuration`. `countdown` confirms, then counts down `countdown` seconds that the cancel button or Escape can still stop. */
  confirmMode?: ConfirmMorphMode;
  /** Milliseconds the confirm button must be held in `hold` mode. */
  holdDuration?: number;
  /** Seconds counted down before the action runs in `countdown` mode. */
  countdown?: number;
  /** Text before the seconds in `countdown` mode, 예: "삭제까지". 기본값은 진행 중 라벨 뒤에 콜론을 붙인 문구예요. */
  countdownLabel?: string;
  /** Runs on confirm. Return a promise to show the pending face; a rejection shows the error face with a retry button. */
  onConfirm?: () => void | Promise<unknown>;
  /** Offering it adds an undo button to the result. Return a promise to show a pending face while it runs. */
  onUndo?: () => void | Promise<unknown>;
  onCancel?: () => void;
  /** Controlled state. Pair it with `onStateChange`. */
  state?: ConfirmMorphState;
  /** Starting state when uncontrolled. */
  defaultState?: ConfirmMorphState;
  onStateChange?: (state: ConfirmMorphState) => void;
  /** Milliseconds before an unanswered question returns to rest. Resting the pointer on the control pauses it. 0 turns it off. */
  confirmTimeout?: number;
  /** Milliseconds a result stays before returning to rest. Resting the pointer on the control pauses it. 0 turns it off. */
  resultTimeout?: number;
  /** A press outside the control cancels an open question. Defaults to true. */
  cancelOnOutsidePress?: boolean;
  disabled?: boolean;
  className?: string;
  /** Receives the root element, which also takes focus while the action is pending. */
  ref?: Ref<HTMLDivElement>;
}

/** A press this soon after the question appears is the tail of the press that opened it, not an answer. */
const ARM_MS = 280;
const { blur } = motionTokens;
type Bezier = [number, number, number, number];
const enter = [...motionTokens.ease.enter] as Bezier;
const standard = [...motionTokens.ease.standard] as Bezier;
/** Duration springs restated as stiffness and damping, so a retarget mid-flight keeps the velocity it already has. */
const physical = ({ visualDuration, bounce }: { visualDuration: number; bounce: number }): Transition => {
  const root = 2 * Math.PI / (visualDuration * 1.2);
  return { type: "spring", stiffness: root * root, damping: 2 * (1 - bounce) * root, mass: 1 };
};
/** Growing follows new content with a little life; shrinking settles without overshoot. */
const GROW = physical(motionTokens.spring.morph), SHRINK = physical(motionTokens.spring.smooth);

const subscribe = () => () => {};
function useReducedFlag() {
  const hydrated = useSyncExternalStore(subscribe, () => true, () => false);
  return !!useReducedMotion() && hydrated;
}

/*
 * Faces never move and never scale: each one sits at its natural width, centred in the pill, so the baseline stays put and
 * the pill's clip reveals it as the width springs. The old face fades out quickly; the new one's parts arrive in reading
 * order, each clearing a small blur.
 */
const faceVariants: Variants = {
  hidden: { opacity: 1 },
  shown: { opacity: 1, transition: { staggerChildren: motionTokens.stagger.item, delayChildren: .05 } },
  gone: { opacity: 0, filter: `blur(${blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: standard } },
};
const partVariants: Variants = {
  hidden: { opacity: 0, filter: `blur(${blur.soft}px)` },
  shown: { opacity: 1, filter: "blur(0px)", transition: { duration: motionTokens.duration.moderate, ease: enter } },
};
const reducedFace: Variants = { hidden: { opacity: 0 }, shown: { opacity: 1, transition: { duration: motionTokens.duration.instant } }, gone: { opacity: 0, transition: { duration: motionTokens.duration.instant } } };
const reducedPart: Variants = { hidden: { opacity: 1 }, shown: { opacity: 1 } };

type FaceKey = ConfirmMorphState | "counting";

function Face({ id, reduced, onSize, children, labelledBy }: { id: FaceKey; reduced: boolean; onSize: (id: FaceKey, width: number) => void; children: ReactNode; labelledBy?: string }) {
  const ref = useRef<HTMLDivElement>(null);
  const present = useIsPresent();
  useLayoutEffect(() => {
    const node = ref.current;
    if (!node || !present) return;
    // Fractional widths, so the spring lands exactly where the pill's natural width is and the hand back to auto is seamless.
    const report = () => onSize(id, node.getBoundingClientRect().width);
    report();
    if (typeof ResizeObserver === "undefined") return;
    const observer = new ResizeObserver(report);
    observer.observe(node);
    return () => observer.disconnect();
  }, [id, onSize, present]);
  return <motion.div ref={ref} className={styles.face} data-face={id} role={labelledBy ? "group" : undefined} aria-labelledby={labelledBy}
    variants={reduced ? reducedFace : faceVariants} initial="hidden" animate="shown" exit="gone" inert={!present || undefined}>{children}</motion.div>;
}

function Part({ reduced, className, children }: { reduced: boolean; className?: string; children: ReactNode }) {
  return <motion.span className={[styles.part, className].filter(Boolean).join(" ")} variants={reduced ? reducedPart : partVariants}>{children}</motion.span>;
}

/** A success disc that pops in with a tick drawing across it, the moment the action lands. */
function Check({ reduced }: { reduced: boolean }) {
  return <svg className={styles.check} viewBox="0 0 18 18" fill="none" aria-hidden="true">
    <motion.circle className={styles.checkDisc} cx="9" cy="9" r="8" style={{ transformOrigin: "9px 9px" }}
      initial={reduced ? false : { scale: .5, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} transition={{ scale: physical(motionTokens.spring.snappy), opacity: { duration: motionTokens.duration.instant } }} />
    <motion.path className={styles.checkTick} d="M5.6 9.3 7.8 11.4 12.4 6.7" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"
      initial={reduced ? false : { pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: motionTokens.duration.moderate, ease: enter, delay: motionTokens.duration.fast }} />
  </svg>;
}

/** A ring that drains with the countdown. */
function Ring({ progress }: { progress: MotionValue<number> }) {
  return <svg className={styles.ring} viewBox="0 0 16 16" fill="none" aria-hidden="true">
    <circle className={styles.ringTrack} cx="8" cy="8" r="6.25" strokeWidth="1.75" />
    <motion.circle className={styles.ringFill} cx="8" cy="8" r="6.25" strokeWidth="1.75" strokeLinecap="round" style={{ pathLength: progress, rotate: -90, transformOrigin: "8px 8px" }} />
  </svg>;
}

/** Seconds that swap in place: the old digit fades up and out, the new one rises in, on tabular figures so the width holds. */
function Seconds({ value, reduced }: { value: number; reduced: boolean }) {
  return <span className={styles.seconds}>
    <AnimatePresence initial={false} mode="popLayout">
      <motion.span key={value} className={styles.digit}
        initial={reduced ? { opacity: 0 } : { opacity: 0, y: 6, filter: `blur(${blur.subtle}px)` }}
        animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
        exit={reduced ? { opacity: 0 } : { opacity: 0, y: -6, filter: `blur(${blur.subtle}px)` }}
        transition={{ duration: motionTokens.duration.standard, ease: enter }}>{value}</motion.span>
    </AnimatePresence>
  </span>;
}

export function ConfirmMorph({
  label, icon, prompt, confirmLabel = "삭제하기", cancelLabel = "취소하기", pendingLabel = "삭제하는 중", doneLabel = "삭제했어요", errorLabel = "끝내지 못했어요",
  retryLabel = "다시 시도하기", undoLabel = "되돌리기", undoingLabel = "복원하는 중", tone = "danger", confirmMode = "press", holdDuration = 1000, countdown = 3, countdownLabel,
  onConfirm, onUndo, onCancel, state: stateProp, defaultState = "idle", onStateChange, confirmTimeout = 6000, resultTimeout = 5000, cancelOnOutsidePress = true,
  disabled = false, className, ref,
}: ConfirmMorphProps) {
  const reduced = useReducedFlag();
  const uid = useId();
  const promptId = `${uid}-prompt`;
  const holdHintId = `${uid}-hold`;
  const rootRef = useRef<HTMLDivElement>(null);
  useImperativeHandle(ref, () => rootRef.current as HTMLDivElement, []);

  const [inner, setInner] = useState<ConfirmMorphState>(defaultState);
  const state = stateProp ?? inner;
  const [working, setWorking] = useState<"confirm" | "undo">("confirm");
  const [counting, setCounting] = useState(false);
  const [announcement, setAnnouncement] = useState("");
  const faceKey: FaceKey = state === "confirming" && counting ? "counting" : state;

  const live = useRef({ state, onStateChange, controlled: stateProp !== undefined });
  useLayoutEffect(() => { live.current = { state, onStateChange, controlled: stateProp !== undefined }; });
  const pendingFocus = useRef(false);
  const run = useRef(0);
  const askedAt = useRef(0);

  const keepFocus = useCallback(() => {
    const root = rootRef.current;
    // Focus follows the control between faces, but only when it was already inside; a timeout never steals focus from elsewhere.
    pendingFocus.current = !!root && (root.contains(document.activeElement) || document.activeElement === document.body);
  }, []);

  const go = useCallback((next: ConfirmMorphState) => {
    if (next !== "confirming") setCounting(false);
    if (next === live.current.state) return;
    keepFocus();
    if (next === "confirming") askedAt.current = performance.now();
    if (!live.current.controlled) setInner(next);
    live.current.state = next;
    live.current.onStateChange?.(next);
  }, [keepFocus]);

  // A controlled parent can move the state on its own; the countdown never survives that.
  const [seenState, setSeenState] = useState(state);
  if (seenState !== state) {
    setSeenState(state);
    if (state !== "confirming") setCounting(false);
  }

  const toIdle = useCallback(() => { run.current++; go("idle"); }, [go]);

  const perform = useCallback(async (kind: "confirm" | "undo") => {
    const handler = kind === "confirm" ? onConfirm : onUndo;
    const token = ++run.current;
    playUiSound(kind === "confirm" ? "confirm" : "click");
    setWorking(kind);
    setCounting(false);
    let result: void | Promise<unknown> | undefined;
    try { result = handler?.(); }
    catch { playUiSound("error"); go("error"); setAnnouncement(errorLabel); return; }
    if (result && typeof (result as Promise<unknown>).then === "function") {
      go("pending");
      setAnnouncement(kind === "confirm" ? pendingLabel : undoingLabel);
      try { await result; }
      catch {
        if (token !== run.current) return;
        playUiSound("error");
        go("error");
        setAnnouncement(errorLabel);
        return;
      }
      if (token !== run.current) return;
    }
    if (kind === "undo") { go("idle"); setAnnouncement("되돌렸어요"); return; }
    go("done");
    setAnnouncement(onUndo ? `${doneLabel}. ${undoLabel} 버튼을 쓸 수 있어요.` : doneLabel);
  }, [doneLabel, errorLabel, go, onConfirm, onUndo, pendingLabel, undoLabel, undoingLabel]);

  const cancel = useCallback((quiet?: unknown) => { if (quiet !== true) playUiSound("close"); onCancel?.(); toIdle(); setAnnouncement("취소했어요"); }, [onCancel, toIdle]);
  const expire = useRef(() => {});
  useLayoutEffect(() => { expire.current = () => { if (live.current.state === "confirming") cancel(true); else toIdle(); }; });

  /* The shape: one pill whose width springs to whichever face is current. At rest it is auto, so it renders right before hydration. */
  const width = useMotionValue<number | "auto">("auto");
  const target = useRef(0);
  const flight = useRef(0);
  // Only the present face reports (a leaving face stops observing), so every report is the current face's width.
  const onFaceSize = useCallback((_id: FaceKey, w: number) => {
    if (Math.abs(w - target.current) < .25) return;
    const from = target.current;
    target.current = w;
    if (!from || reduced) { flight.current++; width.jump("auto"); return; }
    const now = width.get();
    if (now === "auto") width.jump(from);
    const token = ++flight.current;
    animate(width, w, w > (typeof now === "number" ? now : from) ? GROW : SHRINK).then(() => { if (token === flight.current) width.jump("auto"); });
  }, [reduced, width]);

  /* The timeout runs as an invisible clock. A pointer resting on the control holds it, and so does a hidden tab. */
  const drain = useMotionValue(1);
  const clock = useRef<AnimationPlaybackControls | null>(null);
  const holds = useRef({ hover: false, hidden: false });
  const timeout = state === "confirming" && !counting ? confirmTimeout : state === "done" || state === "error" ? resultTimeout : 0;
  const sync = useCallback(() => {
    const control = clock.current;
    if (!control) return;
    const held = holds.current.hover || holds.current.hidden;
    if (held) control.pause(); else control.play();
  }, []);
  useEffect(() => {
    if (!timeout) return;
    drain.jump(1);
    const control = animate(drain, 0, { duration: timeout / 1000, ease: "linear" });
    clock.current = control;
    control.then(() => { if (clock.current === control) { clock.current = null; expire.current(); } });
    sync();
    return () => { if (clock.current === control) clock.current = null; control.stop(); };
  }, [drain, state, sync, timeout]);
  useEffect(() => {
    const onVisibility = () => { holds.current.hidden = document.hidden; sync(); };
    document.addEventListener("visibilitychange", onVisibility);
    return () => document.removeEventListener("visibilitychange", onVisibility);
  }, [sync]);

  /* Countdown: a short grace period after the answer, drawn as a draining ring and ticking seconds. */
  const remaining = useMotionValue(1);
  const [seconds, setSeconds] = useState(countdown);
  const performRef = useRef(perform);
  useLayoutEffect(() => { performRef.current = perform; });
  useMotionValueEvent(remaining, "change", value => setSeconds(Math.max(1, Math.ceil(value * countdown))));
  useEffect(() => {
    if (!counting) return;
    remaining.jump(1);
    const control = animate(remaining, 0, { duration: countdown, ease: "linear" });
    control.then(() => { void performRef.current("confirm"); });
    return () => control.stop();
  }, [counting, countdown, remaining]);

  /* Hold: the confirm button fills with the danger colour from the left while pressed, and drains back if let go early. */
  const hold = useMotionValue(0);
  const holding = useRef<AnimationPlaybackControls | null>(null);
  const fillClip = useTransform(hold, value => `inset(0 ${(1 - value) * 100}% 0 0 round 999px)`);
  const startHold = () => {
    if (holding.current) return;
    const control = animate(hold, 1, { duration: holdDuration / 1000 * (1 - hold.get()), ease: "linear" });
    holding.current = control;
    control.then(() => {
      if (holding.current !== control) return;
      holding.current = null;
      void perform("confirm");
    });
  };
  const releaseHold = () => {
    const control = holding.current;
    if (!control) return;
    holding.current = null;
    control.stop();
    if (hold.get() < 1) {
      animate(hold, 0, reduced ? { duration: 0 } : motionTokens.spring.settle);
      setAnnouncement(`계속 눌러서 확인해 주세요`);
    }
  };
  useEffect(() => { if (state !== "confirming") { holding.current?.stop(); holding.current = null; hold.jump(0); } }, [hold, state]);

  const answer = () => {
    if (performance.now() - askedAt.current < ARM_MS) return;
    if (confirmMode === "countdown") {
      playUiSound("click");
      keepFocus();
      setCounting(true);
      setAnnouncement(`${countdownLabel ?? `${pendingLabel}:`} ${countdown}초`);
      return;
    }
    void perform("confirm");
  };

  // An outside press answers the question with no.
  useEffect(() => {
    if (state !== "confirming" || !cancelOnOutsidePress) return;
    const down = (event: PointerEvent) => { if (!rootRef.current?.contains(event.target as Node)) cancel(); };
    document.addEventListener("pointerdown", down);
    return () => document.removeEventListener("pointerdown", down);
  }, [cancel, cancelOnOutsidePress, state]);

  // Focus moves with the morph: Confirm while asking, the root while counting or working, Undo or Retry on a result, the trigger at rest.
  useLayoutEffect(() => {
    if (!pendingFocus.current) return;
    pendingFocus.current = false;
    const root = rootRef.current;
    if (!root) return;
    const face = root.querySelector<HTMLElement>(`[data-face="${faceKey}"]`);
    const autofocus = face?.querySelector<HTMLElement>("[data-autofocus]:not(:disabled)");
    (autofocus ?? root).focus({ preventScroll: true });
  }, [faceKey]);

  const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
    if (event.key === "Escape") {
      if (state === "confirming") { event.preventDefault(); event.stopPropagation(); cancel(); }
      else if (state === "done" || state === "error") { event.preventDefault(); event.stopPropagation(); toIdle(); }
      return;
    }
    // Enter answers yes from anywhere in the question except a button of its own (Cancel keeps meaning Cancel).
    if (event.key === "Enter" && state === "confirming" && !event.repeat && !(event.target instanceof HTMLButtonElement)) {
      event.preventDefault();
      if (counting) void perform("confirm");
      else if (confirmMode !== "hold") answer();
    }
  };

  const holdKeys = (event: ReactKeyboardEvent<HTMLButtonElement>, down: boolean) => {
    if (event.key !== "Enter" && event.key !== " ") return;
    event.preventDefault();
    if (down) { if (!event.repeat) startHold(); }
    else releaseHold();
  };
  const holdPointer = (event: ReactPointerEvent<HTMLButtonElement>) => {
    if (event.button !== 0) return;
    event.currentTarget.setPointerCapture?.(event.pointerId);
    startHold();
  };

  const shownPrompt = prompt ?? <>{label}?</>;
  const shownCountdown = countdownLabel ?? `${pendingLabel}:`;
  const confirmButton = confirmMode === "hold"
    ? <button type="button" className={styles.confirm} data-autofocus aria-describedby={holdHintId}
      onPointerDown={holdPointer} onPointerUp={releaseHold} onPointerCancel={releaseHold} onLostPointerCapture={releaseHold}
      onKeyDown={event => holdKeys(event, true)} onKeyUp={event => holdKeys(event, false)} onBlur={releaseHold} onContextMenu={event => event.preventDefault()}>
      <span>{confirmLabel}</span>
      <motion.span className={styles.fill} style={{ clipPath: fillClip }} aria-hidden="true"><span>{confirmLabel}</span></motion.span>
    </button>
    : <button type="button" className={styles.confirm} data-autofocus onClick={answer} onKeyDown={event => { if (event.repeat) event.preventDefault(); }}>{confirmLabel}</button>;

  const face = (() => {
    switch (faceKey) {
      case "confirming": return <>
        <Part reduced={reduced} className={styles.prompt}><span id={promptId}>{shownPrompt}</span></Part>
        <Part reduced={reduced}><button type="button" className={styles.secondary} onClick={cancel}>{cancelLabel}</button></Part>
        <Part reduced={reduced}>{confirmButton}</Part>
        {confirmMode === "hold" && <span id={holdHintId} className={styles.srOnly}>길게 눌러 주세요</span>}
      </>;
      case "counting": return <>
        <Part reduced={reduced} className={styles.status}>
          <Ring progress={remaining} />
          <span className={styles.statusText}>{shownCountdown} <Seconds value={seconds} reduced={reduced} /></span>
        </Part>
        <Part reduced={reduced}><button type="button" className={styles.secondary} onClick={cancel}>{cancelLabel}</button></Part>
      </>;
      case "pending": return <Part reduced={reduced} className={styles.status}>
        <UiIcon iconRole="loading" className={styles.spinner} size={16} strokeWidth={1.75} aria-hidden="true" />
        <span className={styles.statusText}>{working === "undo" ? undoingLabel : pendingLabel}</span>
      </Part>;
      case "done": return <>
        <Part reduced={reduced} className={styles.status}><Check reduced={reduced} /><span className={styles.statusText}>{doneLabel}</span></Part>
        {onUndo && <Part reduced={reduced}><button type="button" className={styles.secondary} data-autofocus onClick={() => void perform("undo")}>{undoLabel}</button></Part>}
      </>;
      case "error": return <>
        <Part reduced={reduced} className={styles.status}><UiIcon iconRole="alert" className={styles.alert} size={16} strokeWidth={1.75} aria-hidden="true" /><span className={styles.statusText}>{errorLabel}</span></Part>
        <Part reduced={reduced}><button type="button" className={styles.secondary} data-autofocus onClick={() => void perform(working)}>{retryLabel}</button></Part>
      </>;
      default: return <Part reduced={reduced}><button type="button" className={styles.trigger} data-autofocus disabled={disabled}
        onClick={() => { playUiSound("click"); setAnnouncement(typeof shownPrompt === "string" ? shownPrompt : ""); go("confirming"); }}>
        {icon && <span className={styles.icon} aria-hidden="true">{icon}</span>}
        <span>{label}</span>
      </button></Part>;
    }
  })();

  return <div ref={rootRef} className={[styles.root, className].filter(Boolean).join(" ")} data-state={state} data-face={faceKey} data-tone={tone} data-mode={confirmMode}
    data-disabled={disabled || undefined} tabIndex={-1} onKeyDown={onKeyDown} aria-busy={state === "pending" || undefined}
    onPointerEnter={() => { holds.current.hover = true; sync(); }} onPointerLeave={() => { holds.current.hover = false; sync(); }}
    onPointerCancel={() => { holds.current.hover = false; sync(); }}>
    <motion.div className={styles.surface} style={{ width }}>
      <AnimatePresence initial={false}>
        <Face key={faceKey} id={faceKey} reduced={reduced} onSize={onFaceSize} labelledBy={faceKey === "confirming" ? promptId : undefined}>{face}</Face>
      </AnimatePresence>
    </motion.div>
    <span className={styles.srOnly} role="status" aria-live="polite">{announcement}</span>
  </div>;
}

export default ConfirmMorph;