{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "toaster",
  "type": "registry:ui",
  "title": "Toaster",
  "description": "Layered notification cards with coordinated spring motion, synchronized countdowns, undo actions, invoice reminders and contextual footers.",
  "dependencies": [
    "gsap",
    "@gsap/react",
    "motion",
    "lucide-react",
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/ui/toaster.tsx",
      "content": "\"use client\";\n\nimport {\n  useId,\n  useMemo,\n  useRef,\n  useState,\n  type ReactNode,\n  type Ref,\n} from \"react\";\nimport { useGSAP } from \"@gsap/react\";\nimport gsap from \"gsap\";\nimport { calcGeneratorDuration, spring } from \"motion\";\nimport { ArrowRight, Check, Info, LoaderCircle, X } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport { useMediaQuery } from \"@/lib/use-media-query\";\n\ngsap.registerPlugin(useGSAP);\n\nexport type ToastDismissReason = \"dismiss\" | \"timeout\" | \"action\";\n\nexport interface ToastAction {\n  label: string;\n  onClick: (signal: AbortSignal) => void | Promise<void>;\n  dismissOnSuccess?: boolean;\n  errorMessage?: string;\n}\n\nexport interface ToastItem {\n  id: string;\n  title: string;\n  description?: string;\n  tone?: \"neutral\" | \"info\" | \"danger\" | \"success\";\n  icon?: ReactNode;\n  date?: { month: string; day: string };\n  badges?: readonly string[];\n  duration?: number;\n  countdownLabel?: string;\n  footer?: string;\n  action?: ToastAction;\n  secondaryAction?: ToastAction;\n  actionPlacement?: \"inline\" | \"footer\";\n  dismiss?: ToastDismissReason;\n}\n\nexport interface ToasterProps {\n  items: readonly ToastItem[];\n  onDismiss: (id: string, reason: ToastDismissReason) => void;\n  stiffness?: number;\n  damping?: number;\n  className?: string;\n  ref?: Ref<HTMLDivElement>;\n}\n\nconst bounded = (value: number, fallback: number, min: number, max: number) =>\n  Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;\n\nfunction useToastMotion(stiffness: number, damping: number) {\n  const reduced = useMediaQuery(\"(prefers-reduced-motion: reduce)\");\n  return useMemo(() => {\n    const response = spring({\n      keyframes: [0, 1],\n      stiffness: bounded(stiffness, 240, 80, 500),\n      damping: bounded(damping, 28, 16, 50),\n      restSpeed: 0.001,\n      restDelta: 0.001,\n    });\n    const duration = calcGeneratorDuration(response) / 1000;\n    return {\n      reduced,\n      duration: reduced ? 0 : duration,\n      ease: (progress: number) =>\n        progress === 1 ? 1 : response.next(progress * duration * 1000).value,\n    };\n  }, [stiffness, damping, reduced]);\n}\n\nconst buttonClass =\n  \"inline-flex min-h-8 cursor-pointer items-center justify-center gap-2 rounded-md px-1.5 text-[13px] font-medium outline-none hover:bg-black/5 focus-visible:ring-2 focus-visible:ring-current disabled:cursor-wait disabled:opacity-50 dark:hover:bg-white/10\";\n\nfunction ToastCard({\n  item,\n  onDismiss,\n  motion,\n}: {\n  item: ToastItem;\n  onDismiss: (\n    id: string,\n    reason: ToastDismissReason,\n    restoreFocus: boolean,\n  ) => void;\n  motion: ReturnType<typeof useToastMotion>;\n}) {\n  const titleId = useId();\n  const cardRef = useRef<HTMLDivElement>(null);\n  const secondsRef = useRef<HTMLSpanElement>(null);\n  const progressRef = useRef<HTMLDivElement>(null);\n  const timerRef = useRef<gsap.core.Timeline | null>(null);\n  const pausesRef = useRef(new Set<string>());\n  const closingRef = useRef(false);\n  const restoreFocusRef = useRef(false);\n  const requestRef = useRef<AbortController | null>(null);\n  const callbacksRef = useRef({ onDismiss });\n  const [closing, setClosing] = useState<ToastDismissReason | null>(null);\n  const [busy, setBusy] = useState<string | null>(null);\n  const [error, setError] = useState(\"\");\n  const { contextSafe } = useGSAP({ scope: cardRef });\n  const duration = bounded(item.duration ?? 0, 0, 0, 3600000);\n  const tone = item.tone ?? \"neutral\";\n  const hasFooter = Boolean(\n    item.footer ||\n    (item.countdownLabel && duration > 0) ||\n    (item.action && item.actionPlacement === \"footer\"),\n  );\n\n  useGSAP(\n    () => {\n      callbacksRef.current = { onDismiss };\n    },\n    { dependencies: [onDismiss] },\n  );\n\n  const pause = (reason: string, active: boolean) => {\n    if (active) pausesRef.current.add(reason);\n    else pausesRef.current.delete(reason);\n    timerRef.current?.paused(pausesRef.current.size > 0 || closingRef.current);\n  };\n\n  const dismiss = (reason: ToastDismissReason) => {\n    if (closingRef.current) return;\n    restoreFocusRef.current ||= Boolean(\n      cardRef.current?.contains(document.activeElement),\n    );\n    closingRef.current = true;\n    timerRef.current?.pause();\n    requestRef.current?.abort();\n    setClosing(reason);\n  };\n\n  useGSAP(\n    () => {\n      if (item.dismiss) dismiss(item.dismiss);\n    },\n    { dependencies: [item.dismiss], scope: cardRef },\n  );\n\n  useGSAP(\n    () => {\n      const reduced = window.matchMedia(\n        \"(prefers-reduced-motion: reduce)\",\n      ).matches;\n      gsap.fromTo(\n        cardRef.current,\n        { autoAlpha: 0, y: reduced ? 0 : 18, scale: reduced ? 1 : 0.97 },\n        {\n          autoAlpha: 1,\n          y: 0,\n          scale: 1,\n          duration: reduced ? 0 : motion.duration,\n          ease: motion.ease,\n          overwrite: \"auto\",\n        },\n      );\n      return () => {\n        requestRef.current?.abort();\n      };\n    },\n    { scope: cardRef },\n  );\n\n  useGSAP(\n    () => {\n      if (!closing) return;\n      gsap.to(cardRef.current, {\n        autoAlpha: 0,\n        y: motion.reduced ? 0 : -8,\n        scale: motion.reduced ? 1 : 0.97,\n        duration: motion.reduced ? 0 : 0.18,\n        ease: \"power2.in\",\n        overwrite: true,\n        onComplete: () =>\n          callbacksRef.current.onDismiss(\n            item.id,\n            closing,\n            restoreFocusRef.current,\n          ),\n      });\n    },\n    { dependencies: [closing, motion], scope: cardRef, revertOnUpdate: true },\n  );\n\n  useGSAP(\n    () => {\n      if (!busy || motion.reduced) return;\n      gsap.to(\"[data-toast-spinner]\", {\n        rotation: 360,\n        duration: 0.8,\n        ease: \"none\",\n        repeat: -1,\n      });\n    },\n    {\n      dependencies: [busy, motion.reduced],\n      scope: cardRef,\n      revertOnUpdate: true,\n    },\n  );\n\n  useGSAP(\n    () => {\n      if (!duration) return;\n      const clock = { remaining: duration / 1000 };\n      let lastSecond = Math.ceil(clock.remaining);\n      const timer = gsap.timeline({\n        paused: true,\n        onComplete: () => dismiss(\"timeout\"),\n      });\n      timer.to(\n        clock,\n        {\n          remaining: 0,\n          duration: duration / 1000,\n          ease: \"none\",\n          onUpdate: () => {\n            const second = Math.ceil(clock.remaining);\n            if (second !== lastSecond && secondsRef.current) {\n              secondsRef.current.textContent = `${second} ${second === 1 ? \"second\" : \"seconds\"}`;\n              lastSecond = second;\n            }\n          },\n        },\n        0,\n      );\n      timer.fromTo(\n        progressRef.current,\n        { scaleX: 1 },\n        { scaleX: 0, duration: duration / 1000, ease: \"none\" },\n        0,\n      );\n      timerRef.current = timer;\n      const visibility = () => pause(\"hidden\", document.hidden);\n      visibility();\n      document.addEventListener(\"visibilitychange\", visibility);\n      return () => {\n        document.removeEventListener(\"visibilitychange\", visibility);\n        timerRef.current = null;\n      };\n    },\n    { dependencies: [duration], scope: cardRef, revertOnUpdate: true },\n  );\n\n  const runAction = async (action: ToastAction) => {\n    if (requestRef.current || closingRef.current) return;\n    restoreFocusRef.current = Boolean(\n      cardRef.current?.contains(document.activeElement),\n    );\n    const request = new AbortController();\n    requestRef.current = request;\n    pause(\"action\", true);\n    setBusy(action.label);\n    setError(\"\");\n    try {\n      await action.onClick(request.signal);\n      if (request.signal.aborted) return;\n      if (action.dismissOnSuccess !== false) dismiss(\"action\");\n      pause(\"error\", false);\n    } catch {\n      if (!request.signal.aborted) {\n        setError(\n          action.errorMessage ??\n            \"Couldn’t complete this action. Please try again.\",\n        );\n        pause(\"error\", true);\n      }\n    } finally {\n      if (!request.signal.aborted) {\n        requestRef.current = null;\n        setBusy(null);\n        pause(\"action\", false);\n      }\n    }\n  };\n\n  const highlightFooter = contextSafe(\n    (button: HTMLButtonElement, active: boolean) => {\n      const highlight = button.querySelector(\"[data-toast-footer-highlight]\");\n      if (!highlight) return;\n      gsap.to(highlight, {\n        opacity: active ? 1 : 0,\n        duration: motion.reduced ? 0 : 0.18,\n        ease: \"power2.out\",\n        overwrite: \"auto\",\n      });\n    },\n  );\n\n  const actionButton = (action: ToastAction, footer = false) => (\n    <button\n      type=\"button\"\n      disabled={busy !== null || closing !== null}\n      onClick={() => void runAction(action)}\n      onPointerEnter={\n        footer\n          ? (event) => highlightFooter(event.currentTarget, true)\n          : undefined\n      }\n      onPointerLeave={\n        footer\n          ? (event) => highlightFooter(event.currentTarget, false)\n          : undefined\n      }\n      onFocus={\n        footer\n          ? (event) => highlightFooter(event.currentTarget, true)\n          : undefined\n      }\n      onBlur={\n        footer\n          ? (event) => highlightFooter(event.currentTarget, false)\n          : undefined\n      }\n      className={cn(\n        buttonClass,\n        footer &&\n          \"relative isolate min-h-11 w-full overflow-hidden px-4 hover:bg-transparent dark:hover:bg-transparent\",\n        footer && !item.footer && \"rounded-none rounded-b-2xl\",\n        !footer && \"-ml-1.5\",\n      )}\n    >\n      {footer && (\n        <span\n          data-toast-footer-highlight\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-0 -z-10 bg-black/[0.06] opacity-0 dark:bg-white/[0.08]\"\n        />\n      )}\n      {busy === action.label && (\n        <LoaderCircle data-toast-spinner aria-hidden=\"true\" size={14} />\n      )}\n      {action.label}\n      {footer && item.footer && (\n        <ArrowRight aria-hidden=\"true\" size={14} className=\"ml-auto\" />\n      )}\n    </button>\n  );\n\n  return (\n    <div\n      ref={cardRef}\n      data-slot=\"toast\"\n      data-tone={tone}\n      data-state={closing ? \"closing\" : busy ? \"busy\" : \"open\"}\n      className={cn(\n        \"relative w-full rounded-2xl text-zinc-950 dark:text-zinc-100\",\n        hasFooter && \"bg-zinc-100 p-1 pb-0 dark:bg-[#191919]\",\n        tone === \"danger\" && \"bg-red-50 p-1 dark:bg-[#251414]\",\n        hasFooter && \"pb-0\",\n        tone === \"danger\"\n          ? \"[--toast-accent:#dc2626] dark:[--toast-accent:#f87171]\"\n          : tone === \"success\"\n            ? \"[--toast-accent:#15803d] dark:[--toast-accent:#4ade80]\"\n            : tone === \"info\"\n              ? \"[--toast-accent:#2563eb] dark:[--toast-accent:#60a5fa]\"\n              : \"[--toast-accent:#71717a] dark:[--toast-accent:#a1a1aa]\",\n      )}\n      onPointerEnter={(event) => {\n        if (event.pointerType !== \"touch\") pause(\"hover\", true);\n      }}\n      onPointerLeave={() => pause(\"hover\", false)}\n      onFocusCapture={() => pause(\"focus\", true)}\n      onBlurCapture={(event) => {\n        if (!event.currentTarget.contains(event.relatedTarget))\n          pause(\"focus\", false);\n      }}\n      onKeyDown={(event) => {\n        if (event.key === \"Escape\") {\n          event.preventDefault();\n          event.stopPropagation();\n          dismiss(\"dismiss\");\n        }\n      }}\n    >\n      <article\n        aria-labelledby={titleId}\n        aria-busy={busy !== null}\n        className={cn(\n          \"relative isolate overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-[0_2px_4px_#0000000a] dark:border-white/10 dark:bg-[#0c0c0c] dark:shadow-none\",\n          tone === \"danger\" && \"border-red-200 dark:border-red-500/20\",\n        )}\n      >\n        <div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-0 -z-10 opacity-[0.08] [mask-image:linear-gradient(to_right,transparent_45%,black)]\"\n          style={{\n            backgroundImage:\n              \"radial-gradient(var(--toast-accent) 0.8px, transparent 0.8px)\",\n            backgroundSize: \"7px 7px\",\n          }}\n        />\n        <div className=\"flex items-start gap-3 p-4 pr-11\">\n          {item.date ? (\n            <div className=\"flex size-12 shrink-0 flex-col items-center justify-center rounded-[10px] border border-zinc-100 bg-white text-center shadow-xs dark:border-white/10 dark:bg-black/30\">\n              <span className=\"text-[10px] font-medium uppercase text-zinc-500 dark:text-zinc-400\">\n                {item.date.month}\n              </span>\n              <span className=\"text-xl leading-6 font-medium tabular-nums\">\n                {item.date.day}\n              </span>\n            </div>\n          ) : (\n            item.icon !== null && (\n              <span className=\"flex size-8 shrink-0 items-center justify-center rounded-[9px] bg-zinc-100 text-(--toast-accent) dark:bg-white/5\">\n                {item.icon ??\n                  (tone === \"success\" ? (\n                    <Check size={17} aria-hidden=\"true\" />\n                  ) : (\n                    <Info size={17} aria-hidden=\"true\" />\n                  ))}\n              </span>\n            )\n          )}\n          <div className=\"min-w-0 flex-1\">\n            <h3\n              id={titleId}\n              className=\"text-[14px] leading-5 font-medium tracking-[-0.025em] wrap-anywhere\"\n            >\n              {item.title}\n            </h3>\n            {item.description && (\n              <p className=\"mt-1 text-[13px] leading-5 text-zinc-500 wrap-anywhere dark:text-zinc-400\">\n                {item.description}\n              </p>\n            )}\n            {(item.secondaryAction ||\n              (item.action && item.actionPlacement !== \"footer\")) && (\n              <div className=\"mt-2 flex flex-wrap items-center gap-x-3 gap-y-1\">\n                {item.secondaryAction ? (\n                  actionButton(item.secondaryAction)\n                ) : (\n                  <button\n                    type=\"button\"\n                    className={cn(\n                      buttonClass,\n                      \"-ml-1.5 text-zinc-500 dark:text-zinc-400\",\n                    )}\n                    onClick={() => dismiss(\"dismiss\")}\n                    disabled={busy !== null || closing !== null}\n                  >\n                    Dismiss\n                  </button>\n                )}\n                {item.action &&\n                  item.actionPlacement !== \"footer\" &&\n                  actionButton(item.action)}\n              </div>\n            )}\n            {error && (\n              <p\n                role=\"alert\"\n                className=\"mt-2 text-xs leading-5 text-red-600 dark:text-red-400\"\n              >\n                {error}\n              </p>\n            )}\n          </div>\n        </div>\n        <button\n          type=\"button\"\n          aria-label={`Dismiss ${item.title}`}\n          disabled={busy !== null || closing !== null}\n          onClick={() => dismiss(\"dismiss\")}\n          className={cn(\n            buttonClass,\n            \"absolute top-2 right-2 size-8 p-0 text-zinc-500 dark:text-zinc-400\",\n          )}\n        >\n          <X size={16} aria-hidden=\"true\" />\n        </button>\n        {item.badges && item.badges.length > 0 && (\n          <div className=\"flex flex-wrap gap-1.5 border-t border-zinc-100 bg-zinc-50/80 px-4 py-2.5 dark:border-white/5 dark:bg-white/[0.025]\">\n            {item.badges.map((badge, index) => (\n              <span\n                key={`${badge}-${index}`}\n                className={cn(\n                  \"rounded-md bg-zinc-200/70 px-2 py-1 text-[10px] leading-3 font-medium dark:bg-white/10\",\n                  index === 0 && \"bg-(--toast-accent)/10 text-(--toast-accent)\",\n                )}\n              >\n                {badge}\n              </span>\n            ))}\n          </div>\n        )}\n        {duration > 0 && (\n          <div\n            aria-hidden=\"true\"\n            className=\"absolute inset-x-0 bottom-0 h-0.5 bg-(--toast-accent)/10\"\n          >\n            <div\n              ref={progressRef}\n              className=\"h-full origin-left bg-(--toast-accent)/50\"\n            />\n          </div>\n        )}\n      </article>\n      {hasFooter && (\n        <div\n          className={cn(\n            \"flex min-h-8 flex-wrap items-center justify-center gap-x-1 px-2 text-center text-[11px] leading-5 text-zinc-500 dark:text-zinc-400\",\n            tone === \"danger\" && \"text-red-600 dark:text-red-400\",\n            item.action &&\n              item.actionPlacement === \"footer\" &&\n              !item.footer &&\n              \"-mx-1 overflow-hidden rounded-b-2xl px-0\",\n          )}\n        >\n          {item.countdownLabel && duration > 0 ? (\n            <>\n              <span>{item.countdownLabel}</span>\n              <span\n                ref={secondsRef}\n                className=\"font-medium text-(--toast-accent) tabular-nums\"\n              >\n                {Math.ceil(duration / 1000)}{\" \"}\n                {Math.ceil(duration / 1000) === 1 ? \"second\" : \"seconds\"}\n              </span>\n            </>\n          ) : (\n            item.footer && (\n              <span className={cn(item.action && \"mr-auto pl-2 text-left\")}>\n                {item.footer}\n              </span>\n            )\n          )}\n          {item.action && item.actionPlacement === \"footer\" && (\n            <div className={cn(\"flex\", item.footer ? \"ml-auto\" : \"w-full\")}>\n              {actionButton(item.action, true)}\n            </div>\n          )}\n        </div>\n      )}\n    </div>\n  );\n}\n\nfunction measureToastPositions(root: HTMLDivElement | null) {\n  const rows = Array.from(\n    root?.querySelectorAll<HTMLLIElement>(\"[data-toast-id]\") ?? [],\n  );\n  return rows.map((row) => {\n    const rect = row.getBoundingClientRect();\n    const currentX = Number(gsap.getProperty(row, \"x\")) || 0;\n    const currentY = Number(gsap.getProperty(row, \"y\")) || 0;\n    return {\n      row,\n      id: row.dataset.toastId ?? \"\",\n      x: rect.left - currentX,\n      y: rect.top - currentY,\n      currentX,\n      currentY,\n    };\n  });\n}\n\nexport function Toaster({\n  items,\n  onDismiss,\n  stiffness = 240,\n  damping = 28,\n  className,\n  ref,\n}: ToasterProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const positionsRef = useRef(new Map<string, { x: number; y: number }>());\n  const restoreFocusRef = useRef(false);\n  const motion = useToastMotion(stiffness, damping);\n  const itemKey = JSON.stringify(items.map((item) => item.id));\n\n  useGSAP(\n    () => {\n      const positions = measureToastPositions(rootRef.current);\n      for (const { row, id, x, y, currentX, currentY } of positions) {\n        const previous = positionsRef.current.get(id);\n        if (previous) {\n          gsap.fromTo(\n            row,\n            { x: previous.x - x + currentX, y: previous.y - y + currentY },\n            {\n              x: 0,\n              y: 0,\n              duration: motion.duration,\n              ease: motion.ease,\n              overwrite: true,\n            },\n          );\n        }\n      }\n      positionsRef.current = new Map(\n        positions.map(({ id, x, y }) => [id, { x, y }]),\n      );\n      if (restoreFocusRef.current) {\n        (\n          positions[0]?.row.querySelector<HTMLButtonElement>(\n            \"button:not(:disabled)\",\n          ) ?? rootRef.current\n        )?.focus({ preventScroll: true });\n        restoreFocusRef.current = false;\n      }\n    },\n    { dependencies: [itemKey, motion, className], scope: rootRef },\n  );\n\n  return (\n    <div\n      ref={(node) => {\n        rootRef.current = node;\n        if (typeof ref === \"function\") ref(node);\n        else if (ref) ref.current = node;\n      }}\n      role=\"region\"\n      aria-label=\"Notifications\"\n      tabIndex={-1}\n      className=\"w-full outline-none focus-visible:ring-2 focus-visible:ring-zinc-400\"\n    >\n      <ol\n        className={cn(\n          \"relative mx-auto flex w-full max-w-[440px] flex-col gap-4\",\n          className,\n        )}\n      >\n        {items.map((item) => (\n          <li\n            key={item.id}\n            data-toast-id={item.id}\n            className=\"relative min-w-0\"\n          >\n            <ToastCard\n              item={item}\n              motion={motion}\n              onDismiss={(id, reason, restoreFocus) => {\n                positionsRef.current = new Map(\n                  measureToastPositions(rootRef.current).map(\n                    ({ id: toastId, x, y }) => [toastId, { x, y }],\n                  ),\n                );\n                const row = Array.from(\n                  rootRef.current?.querySelectorAll<HTMLLIElement>(\n                    \"[data-toast-id]\",\n                  ) ?? [],\n                ).find((element) => element.dataset.toastId === id);\n                const activeElement = document.activeElement;\n                const containsFocus = Boolean(row?.contains(activeElement));\n                restoreFocusRef.current ||=\n                  containsFocus ||\n                  (restoreFocus && activeElement === document.body);\n                onDismiss(id, reason);\n              }}\n            />\n          </li>\n        ))}\n      </ol>\n      <div className=\"sr-only\" aria-live=\"polite\" aria-atomic=\"true\">\n        {items.map((item) => item.title).join(\". \")}\n      </div>\n    </div>\n  );\n}\n\nexport default Toaster;\n",
      "type": "registry:ui",
      "target": "@ui/toaster.tsx"
    },
    {
      "path": "src/lib/use-media-query.ts",
      "content": "\"use client\";\n\nimport { useCallback, useSyncExternalStore } from \"react\";\n\nexport function useMediaQuery(query: string) {\n  const subscribe = useCallback(\n    (onChange: () => void) => {\n      const media = window.matchMedia(query);\n      media.addEventListener(\"change\", onChange);\n      return () => media.removeEventListener(\"change\", onChange);\n    },\n    [query],\n  );\n  const getSnapshot = useCallback(() => window.matchMedia(query).matches, [query]);\n  return useSyncExternalStore(subscribe, getSnapshot, () => false);\n}\n",
      "type": "registry:lib"
    }
  ]
}
