# 토스트

잠시 나타났다가 사라지고 밀어서 닫을 수도 있는 알림이에요.

- Type: component
- Theme: base
- Tags: arc, component, toast, notification, feedback, overlay, swipe
- English title: Toast
- URL: https://dsui.vercel.app/items/toast

## Controls

| Key | Type | Label | Default |
| --- | --- | --- | --- |
| title | text | 제목 | 저장했어요 |
| description | text | 설명 | 변경한 내용이 안전하게 보관돼요 |

## Source

### toast.module.css

```css
.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.tsx

```tsx
"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>
    </>
  );
}

```
