컴포넌트
토스트
잠시 나타났다가 사라지고 밀어서 닫을 수도 있는 알림이에요.
- #arc
- #component
- #toast
- #notification
- #feedback
- #overlay
- #swipe
1280px
설치
이 명령 한 줄이면 필요한 파일과 패키지가 함께 설치돼요.
터미널터미널터미널bash
npx shadcn@latest add https://dsui.vercel.app/r/toast.json파일
components/arc/toast/toast.module.csscomponents/arc/toast/toast.tsx
npm 패키지
motion@^14.2.0
함께 오는 항목
toast.module.csstoast.module.csstoast.module.csscss
.toast {
display: flex;
width: min(100%, 26rem);
min-width: 0;
align-items: flex-start;
gap: 0.75rem;
padding: 0.875rem 0.875rem 0.875rem 1rem;
border: 1px solid var(--float-edge, color-mix(in oklab, var(--surface) 94%, var(--background)));
border-radius: var(--radius-panel);
background: color-mix(in oklab, var(--surface) 94%, var(--background));
color: var(--foreground);
letter-spacing: var(--tracking-body);
box-shadow: var(--shadow-floating);
}
.icon {
display: grid;
width: 1.875rem;
height: 1.875rem;
flex: 0 0 auto;
place-items: center;
border: 1px solid transparent;
border-radius: var(--radius-pill);
background: color-mix(in oklab, var(--success) 10%, var(--surface));
color: var(--success);
}
.frame {
display: block;
min-width: 0;
flex: 1;
}
.copy {
position: relative;
display: grid;
min-width: 0;
gap: 0.125rem;
/* Centers the title's first line on the 30px status icon. */
padding-top: 0.3125rem;
}
.title {
position: relative;
display: block;
min-width: 0;
font-size: var(--text-sm);
font-weight: 500;
line-height: var(--leading-body);
}
.line {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.description {
display: block;
color: var(--text-secondary);
font-size: var(--text-sm);
line-height: var(--leading-body);
}
.close {
display: grid;
width: 2rem;
height: 2rem;
flex: 0 0 auto;
place-items: center;
margin: -0.0625rem -0.125rem 0 0;
border: 0;
border-radius: var(--radius-pill);
background: transparent;
color: var(--text-muted);
cursor: pointer;
transition: transform var(--duration-spring) var(--ease-spring),
background-color var(--duration-fast) var(--ease-standard),
color var(--duration-fast) var(--ease-standard);
}
@media (hover: hover) and (pointer: fine) {
.close:hover {
background: var(--surface-muted);
color: var(--foreground);
}
}
/* Quick press, spring release. */
.close:active {
transform: scale(0.96);
transition-duration: var(--duration-instant);
transition-timing-function: var(--ease-standard);
}
@media (prefers-reduced-motion: reduce) {
.close { transition: none; }
.close:active { transform: none; }
}
.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
toast.tsxtoast.tsxtoast.tsxtsx
"use client";
import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, type ReactNode } from "react";
import { AnimatePresence, animate, motion, useIsPresent, useMotionValue, type AnimationPlaybackControls, type HTMLMotionProps, type MotionProps, type PanInfo, type TargetAndTransition, type Transition, type Variants } from "motion/react";
import { useReducedMotion } from "../lib/use-reduced-motion";
import { motionTokens } from "../lib/motion-tokens";
import styles from "./toast.module.css";
import { UiIcon } from "../theme/ui-icon";
import { playUiSound } from "../sound/play";
export interface ToastProps { title: string; description?: string; open?: boolean; onOpenChange?: (open: boolean) => void; duration?: number; }
const subscribeHydration = () => () => {};
const exitFast: Transition = { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] };
const enter: Transition = { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] };
const textIn: TargetAndTransition = { opacity: 0, y: "0.3em", filter: `blur(${motionTokens.blur.soft}px)` };
const textOut: TargetAndTransition = { opacity: 0, y: "-0.3em", filter: `blur(${motionTokens.blur.subtle}px)`, transition: exitFast };
const textShown: TargetAndTransition = { opacity: 1, y: "0em", filter: "blur(0px)" };
const fadeOut: TargetAndTransition = { opacity: 0, transition: { duration: motionTokens.duration.instant } };
/** A swipe past this distance (px) or speed (px/s) dismisses the toast in the direction it was thrown. */
const swipe = { distance: 80, velocity: 480 };
/** Outgoing copies are hidden from assistive tech while they fade, so the live region reads only the current text. */
function Swap(props: HTMLMotionProps<"span">) {
const present = useIsPresent();
return <motion.span {...props} aria-hidden={present ? props["aria-hidden"] : true} />;
}
/** Follows its content height. After `morphKey` changes, the height springs from the old size to the new one and then returns to auto, so passive reflows (a resize, a font swap) follow instantly. It clips only while moving, so focus rings stay visible at rest. */
function HeightFrame({ reduce, morphKey, children }: { reduce: boolean | null; morphKey: string; children: ReactNode }) {
const frame = useRef<HTMLDivElement>(null);
const content = useRef<HTMLDivElement>(null);
const height = useMotionValue<number | "auto">("auto");
const changedAt = useRef(0);
useLayoutEffect(() => { changedAt.current = performance.now(); }, [morphKey]);
useEffect(() => {
const node = content.current;
if (!node || typeof ResizeObserver === "undefined") return;
let last: number | undefined;
let controls: AnimationPlaybackControls | undefined;
const settle = () => { height.jump("auto"); if (frame.current) Object.assign(frame.current.style, { overflow: "", height: "auto" }); };
const observer = new ResizeObserver(([entry]) => {
if (!entry) return;
const next = entry.borderBoxSize?.[0]?.blockSize ?? node.offsetHeight;
const current = height.get();
const from = typeof current === "number" ? current : last;
last = next;
controls?.stop();
if (reduce || from === undefined || from === next || performance.now() - changedAt.current > 120) return settle();
// Pin the old height before this frame paints, then spring to the new one.
if (frame.current) Object.assign(frame.current.style, { overflow: "hidden", height: `${from}px` });
controls = animate(height, [from, next], { ...motionTokens.spring.smooth, onComplete: settle });
});
observer.observe(node);
return () => { observer.disconnect(); controls?.stop(); };
}, [height, reduce]);
return <motion.div ref={frame} className={styles.frame} style={{ height }}>
<div ref={content} className={styles.copy}>{children}</div>
</motion.div>;
}
export default function Toast({ title, description, open = true, onOpenChange, duration = 4500 }: ToastProps) {
const reduce = useReducedMotion();
// Toasts that mount after hydration rise in from their edge; server-rendered ones start settled.
const hydrated = useSyncExternalStore(subscribeHydration, () => true, () => false);
const [enterOnMount] = useState(hydrated);
const [dismissed, setDismissed] = useState(false);
const [throwX, setThrowX] = useState(0);
const [lastOpen, setLastOpen] = useState(open);
if (open !== lastOpen) { setLastOpen(open); if (open) { setDismissed(false); setThrowX(0); } }
const visible = open && !dismissed;
const surface = useRef<HTMLDivElement>(null);
// The drag offset is owned here, so a release hands its velocity straight to the spring home or to the throw.
const x = useMotionValue(0);
const releaseVelocity = useRef(0);
/** Closing tells the parent at once, while the exit plays out here, so a new toast can be raised mid-exit and simply turns back. */
function dismiss(to: number) {
playUiSound("close");
setThrowX(to);
setDismissed(true);
onOpenChange?.(false);
}
// The parent's handler is read through a ref, so a new function on every render does not restart the countdown.
const closeRef = useRef(onOpenChange);
useLayoutEffect(() => { closeRef.current = onOpenChange; });
// Hovering or focusing the toast pauses the countdown (WCAG 2.2.1); leaving resumes with the time that was left.
const [paused, setPaused] = useState(false);
const timeLeft = useRef(duration);
useEffect(() => { timeLeft.current = duration; }, [visible, duration]);
const hasHandler = !!onOpenChange;
useEffect(() => {
if (!visible || !hasHandler || duration <= 0 || paused) return;
const startedAt = Date.now();
const timer = window.setTimeout(() => { setThrowX(0); setDismissed(true); closeRef.current?.(false); }, timeLeft.current);
return () => { window.clearTimeout(timer); timeLeft.current = Math.max(0, timeLeft.current - (Date.now() - startedAt)); };
}, [visible, hasHandler, duration, paused]);
function handleDragEnd(_: PointerEvent | MouseEvent | TouchEvent, info: PanInfo) {
const direction = Math.sign(info.offset.x || info.velocity.x);
// A short swipe springs back with its release velocity and a slight settle instead of drifting home.
if (Math.abs(info.offset.x) < swipe.distance && Math.abs(info.velocity.x) < swipe.velocity) {
animate(x, 0, { ...motionTokens.spring.snappy, velocity: info.velocity.x });
return;
}
releaseVelocity.current = info.velocity.x;
dismiss(direction * ((surface.current?.offsetWidth ?? 320) + 48));
}
const variants: Variants = reduce ? {
hidden: { opacity: 0 },
shown: { opacity: 1, transition: { duration: motionTokens.duration.instant } },
exit: fadeOut,
} : {
// x resets here because the drag value outlives a thrown toast; the next one must rise from the center.
hidden: { opacity: 0, x: 0, y: 16, scale: .96 },
// x returns to center too, in case the toast is raised again while a throw is still leaving.
shown: { opacity: 1, x: 0, y: 0, scale: 1, transition: { ...motionTokens.spring.morph, opacity: enter } },
// A thrown toast keeps its release velocity; a closed one sinks back toward the edge it came from.
// The throw settles loosely: it is invisible once the fade ends, so no long unseen tail keeps it mounted.
exit: (to: number) => to
? { x: to, opacity: 0, transition: { x: { ...motionTokens.spring.settle, velocity: releaseVelocity.current, restDelta: 2, restSpeed: 40 }, opacity: { duration: motionTokens.duration.exit } } }
: { opacity: 0, y: 8, scale: .97, transition: { duration: motionTokens.duration.exit, ease: [...motionTokens.ease.standard] } },
};
const swap: MotionProps = { initial: reduce ? { opacity: 0 } : textIn, animate: textShown, exit: reduce ? fadeOut : textOut, transition: reduce ? { duration: motionTokens.duration.instant } : enter };
return (
<>
{/* Mounted before and after the toast, so screen readers announce the text when it is put in. */}
<span className={styles.srOnly} role="status" aria-live="polite" aria-atomic="true">{visible ? `${title}${description ? `. ${description}` : ""}` : ""}</span>
<AnimatePresence initial={enterOnMount} custom={throwX}>
{visible && <motion.div
key="toast"
ref={surface}
className={styles.toast}
role="group"
aria-label={title}
onPointerEnter={() => setPaused(true)}
onPointerLeave={() => setPaused(false)}
onFocus={() => setPaused(true)}
onBlur={() => setPaused(false)}
custom={throwX}
variants={variants}
initial="hidden"
animate="shown"
exit="exit"
style={{ x }}
// Drag writes touch-action into the markup, so the reduced motion switch waits for hydration to keep server and client in step.
drag={reduce && hydrated ? false : "x"}
dragMomentum={false}
onDragEnd={handleDragEnd}
>
<span className={styles.icon} aria-hidden="true">
<svg width={16} height={16} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.25} strokeLinecap="round" strokeLinejoin="round">
<motion.path d="M4 12.5l5 5L20 6.5" initial={reduce ? false : { pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter], delay: motionTokens.duration.instant }} />
</svg>
</span>
{/* The copy column follows its content height, so a longer message grows the toast instead of snapping it. */}
<HeightFrame reduce={reduce} morphKey={`${title}\n${description ?? ""}`}>
<strong className={styles.title}><AnimatePresence mode="popLayout" initial={false}><Swap key={title} className={styles.line} {...swap}>{title}</Swap></AnimatePresence></strong>
<AnimatePresence mode="popLayout" initial={false}>{description ? <Swap key={description} className={styles.description} {...swap}>{description}</Swap> : null}</AnimatePresence>
</HeightFrame>
<button
className={styles.close}
type="button"
aria-label="알림 닫기"
onClick={() => dismiss(0)}
>
<UiIcon iconRole="close" width={16} height={16} strokeWidth={2} aria-hidden="true" />
</button>
</motion.div>}
</AnimatePresence>
</>
);
}