컴포넌트
액션 버튼
누르면 진행 중, 완료 상태로 바뀌며 결과를 알려 주는 버튼이에요.
- #arc
- #component
- #button
- #async
- #feedback
- #state
1280px
설치
이 명령 한 줄이면 필요한 파일과 패키지가 함께 설치돼요.
터미널터미널터미널bash
npx shadcn@latest add https://dsui.vercel.app/r/action-button.json파일
components/arc/action-button/action-button.module.csscomponents/arc/action-button/action-button.tsx
npm 패키지
motion@^14.2.0
함께 오는 항목
action-button.module.cssaction-button.module.cssaction-button.module.csscss
/* The button hugs its label: text morphs in place and the width follows on a spring, so no state ever snaps the layout. */
.button { position: relative; display: inline-flex; min-height: var(--control-height-md); align-items: center; justify-content: center; overflow: hidden; padding: 0 var(--space-5); border: 1px solid var(--accent); border-radius: var(--radius-control); background: var(--accent); color: var(--accent-foreground); font: inherit; font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); cursor: pointer; -webkit-tap-highlight-color: transparent; transition: background var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), box-shadow var(--duration-fast) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard); }
@media (hover: hover) and (pointer: fine) { .button:hover:not(:disabled) { box-shadow: var(--shadow-resting); } }
.button:disabled { cursor: not-allowed; }
.button:disabled:not([data-state="pending"]) { opacity: .7; }
.button[data-state="pending"] { cursor: progress; }
.content { display: inline-flex; min-height: 1.25rem; align-items: center; justify-content: center; gap: var(--space-2); white-space: nowrap; }
.morph { display: inline-flex; min-width: 0; }
.morph[data-morphing] { clip-path: inset(-.6em 0 -.6em -.3em); }
.glyphs { position: relative; display: inline-flex; flex: none; white-space: pre; }
.glyph { display: inline-block; }
.iconSlot { display: grid; width: 17px; height: 17px; flex: 0 0 17px; place-items: center; }
.phase { grid-area: 1 / 1; display: grid; width: 17px; height: 17px; place-items: center; }
.arrow { flex: none; }
.statusIcon { color: currentColor; }
.spinner { display: inline-block; width: var(--space-4); height: var(--space-4); border: 1.5px solid currentColor; border-right-color: transparent; border-radius: var(--radius-pill); animation: spin .7s linear infinite; }
.visuallyHidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .button { transition: none; } .spinner { animation: none; } }
action-button.tsxaction-button.tsxaction-button.tsxtsx
"use client";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import type { ButtonHTMLAttributes, RefObject } from "react";
import { AnimatePresence, animate, motion, useMotionValue } from "motion/react";
import { useReducedMotion } from "../lib/use-reduced-motion";
import type { TargetAndTransition, Variants } from "motion/react";
import { motionTokens } from "../lib/motion-tokens";
import styles from "./action-button.module.css";
import { playUiSound } from "../sound/play";
import { UiIcon } from "../theme/ui-icon";
export interface ActionButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onClick" | "onDrag" | "onDragEnd" | "onDragStart" | "onAnimationStart"> {
label: string;
successLabel?: string;
pendingLabel?: string;
onAction: () => void | Promise<void>;
resetAfterMs?: number;
onActionError?: (error: unknown) => void;
}
const pressVariants: Variants = {
pressed: (button: RefObject<HTMLButtonElement | null>) => ({ scale: (button.current?.offsetWidth ?? 0) > 220 ? .985 : .97, transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } }),
};
const rest: TargetAndTransition = { opacity: 1, y: 0, scale: 1, filter: "blur(0px)" };
const glyphIn: TargetAndTransition = { opacity: 0, y: 5, filter: `blur(${motionTokens.blur.soft}px)` };
const glyphOut: TargetAndTransition = { opacity: 0, y: -4, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } };
const iconIn: TargetAndTransition = { opacity: 0, scale: .6, filter: `blur(${motionTokens.blur.subtle}px)` };
const iconOut: TargetAndTransition = { ...iconIn, transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } };
/** The arrow leaves in the direction of the action and returns from behind once the button resets. */
const arrowIn: TargetAndTransition = { opacity: 0, x: -6, filter: `blur(${motionTokens.blur.subtle}px)` };
const arrowOut: TargetAndTransition = { opacity: 0, x: 8, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } };
const iconRest: TargetAndTransition = { ...rest, x: 0 };
const fadeIn: TargetAndTransition = { ...rest, opacity: 0 };
const fadeOut: TargetAndTransition = { opacity: 0, transition: { duration: motionTokens.duration.instant } };
/** Scale rides the spring; opacity and blur tween so blur never overshoots below zero. */
const iconEnter = { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] }, filter: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] } } as const;
/** Springs the wrapper to the natural width of its content when the text changes, so new text never snaps the layout.
* Other resizes (a late web font, a parent reflow) jump straight to the new width, so nothing wobbles on first paint. */
function useMorphWidth(content: RefObject<HTMLElement | null>, key: string, reduced: boolean) {
const width = useMotionValue<number | "auto">("auto");
const lastKey = useRef(key), armedUntil = useRef(0);
useLayoutEffect(() => {
if (lastKey.current === key) return;
lastKey.current = key;
armedUntil.current = performance.now() + 700;
}, [key]);
useEffect(() => {
const node = content.current, slot = node?.parentElement;
if (!node || !slot || typeof ResizeObserver === "undefined") return;
let measured = false;
const observer = new ResizeObserver(([entry]) => {
if (!entry) return;
const next = entry.contentRect.width;
if (!next || !measured || reduced || performance.now() > armedUntil.current) { measured = next > 0; width.jump(next || "auto"); delete slot.dataset.morphing; return; }
slot.dataset.morphing = "";
animate(width, next, { ...motionTokens.spring.morph, onComplete: () => { delete slot.dataset.morphing; } });
});
observer.observe(node);
return () => observer.disconnect();
}, [content, reduced, width]);
return width;
}
/** The success tick draws itself from its short stroke, the way a hand would write it. */
function DrawnCheck({ reduced }: { reduced: boolean }) {
return <svg className={styles.statusIcon} width={17} height={17} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<motion.path d="M4 12l5 5L20 6" initial={reduced ? false : { pathLength: 0, opacity: 0 }} animate={{ pathLength: 1, opacity: 1 }} transition={{ pathLength: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter], delay: motionTokens.delay.micro }, opacity: { duration: motionTokens.duration.micro, delay: motionTokens.delay.micro } }} />
</svg>;
}
type Glyph = { id: string; char: string; order: number };
const toGlyphs = (chars: string[], seq: number): Glyph[] => chars.map((char, order) => ({ id: `${seq}:${order}`, char, order }));
/** Shared leading and trailing characters keep their identity, so only the changed run of text is replaced. */
function useGlyphs(text: string) {
const [state, setState] = useState(() => ({ text, seq: 0, glyphs: toGlyphs([...text], 0) }));
if (state.text === text) return state.glyphs;
const prev = [...state.text], next = [...text];
let start = 0, end = 0;
while (start < prev.length && start < next.length && prev[start] === next[start]) start++;
while (end < prev.length - start && end < next.length - start && prev[prev.length - 1 - end] === next[next.length - 1 - end]) end++;
if (start < 2) start = 0;
if (end < 2) end = 0;
const seq = state.seq + 1;
const glyphs = [...state.glyphs.slice(0, start), ...toGlyphs(next.slice(start, next.length - end), seq), ...state.glyphs.slice(state.glyphs.length - end)];
setState({ text, seq, glyphs });
return glyphs;
}
/** Morphs one label into the next: kept letters glide into place, new ones rise in from a soft blur, and the width follows on a spring. */
function MorphText({ text, reduced }: { text: string; reduced: boolean }) {
const glyphs = useGlyphs(text);
const rowRef = useRef<HTMLSpanElement>(null);
const width = useMorphWidth(rowRef, text, reduced);
return <motion.span className={styles.morph} style={{ width }} aria-hidden="true">
<span ref={rowRef} className={styles.glyphs}>
<AnimatePresence mode="popLayout" initial={false}>
{glyphs.map(glyph => <motion.span key={glyph.id} className={styles.glyph} layout={reduced ? false : "position"} layoutDependency={text} initial={reduced ? fadeIn : glyphIn} animate={rest} exit={reduced ? fadeOut : glyphOut} transition={reduced ? { duration: motionTokens.duration.instant } : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter], delay: Math.min(glyph.order * motionTokens.stagger.char, .1), layout: motionTokens.spring.morph }}>{glyph.char}</motion.span>)}
</AnimatePresence>
</span>
</motion.span>;
}
export function ActionButton({ label, successLabel = "저장했어요", pendingLabel = "저장하는 중", onAction, resetAfterMs = 2400, onActionError, className, disabled, ...props }: ActionButtonProps) {
const [state, setState] = useState<"idle" | "pending" | "success">("idle");
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const reduceMotion = useReducedMotion() ?? false;
const text = state === "pending" ? pendingLabel : state === "success" ? successLabel : label;
useEffect(() => () => { if (resetTimer.current) clearTimeout(resetTimer.current); }, []);
async function run() {
if (state === "pending") return;
playUiSound("click");
if (resetTimer.current) clearTimeout(resetTimer.current);
setState("pending");
try {
await onAction();
playUiSound("success");
setState("success");
if (resetAfterMs > 0) resetTimer.current = setTimeout(() => setState("idle"), resetAfterMs);
} catch (error) {
playUiSound("error");
setState("idle");
onActionError?.(error);
}
}
const pending = state === "pending";
const arrow = state === "idle";
// Pending stays focusable (aria-disabled instead of disabled), so a keyboard user keeps focus through the whole save.
return <motion.button {...props} ref={buttonRef} tabIndex={props.tabIndex ?? 0} type={props.type ?? "button"} className={[styles.button, className].filter(Boolean).join(" ")} disabled={disabled} aria-disabled={pending ? true : props["aria-disabled"]} aria-busy={pending} data-state={state} onClick={run} custom={buttonRef} variants={pressVariants} whileTap={reduceMotion || disabled || pending ? undefined : "pressed"} transition={motionTokens.spring.snappy}>
<span className={styles.content} aria-hidden="true">
<MorphText text={text} reduced={reduceMotion} />
<span className={styles.iconSlot}><AnimatePresence initial={false}><motion.span key={state} className={styles.phase} initial={reduceMotion ? fadeIn : arrow ? arrowIn : iconIn} animate={iconRest} exit={reduceMotion ? fadeOut : arrow ? arrowOut : iconOut} transition={reduceMotion ? { duration: motionTokens.duration.instant } : iconEnter}>{pending ? <span className={styles.spinner} /> : state === "success" ? <DrawnCheck reduced={reduceMotion} /> : <UiIcon iconRole="arrow-right" className={styles.arrow} width={17} height={17} />}</motion.span></AnimatePresence></span>
</span>
<span className={styles.visuallyHidden}>{label}</span>
<span className={styles.visuallyHidden} role="status">{state === "pending" ? pendingLabel : state === "success" ? successLabel : ""}</span>
</motion.button>;
}
export default ActionButton;