{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "toast",
  "type": "registry:ui",
  "title": "Toast",
  "description": "Stacked toasts that fan out on hover, swipe away and update in place.",
  "categories": [
    "notifications"
  ],
  "dependencies": [
    "@base-ui/react",
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/toast.tsx",
      "content": "\"use client\";\nimport { Toast } from \"@base-ui/react/toast\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from \"motion/react\";\nimport { useEffect } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Info, Warning, X } from \"@/lib/icons\";\nimport { ease } from \"@/lib/motion\";\n\nexport type ToastTone = \"neutral\" | \"success\" | \"error\" | \"warning\" | \"info\" | \"loading\";\nexport type ToastPosition = \"top-left\" | \"top-center\" | \"top-right\" | \"bottom-left\" | \"bottom-center\" | \"bottom-right\";\n\nexport type ToastAction = {\n  label: string;\n  /** Runs, then the toast closes. Call `event.preventDefault()` to keep it open (to update it in place, say). */\n  onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;\n};\n\nexport type ToastOptions = {\n  /** Reusing an id updates that toast in place instead of stacking a duplicate. */\n  id?: string;\n  title?: React.ReactNode;\n  description?: React.ReactNode;\n  tone?: ToastTone;\n  /** Replace the tone's icon, or `false` for none. */\n  icon?: React.ReactNode | false;\n  action?: ToastAction;\n  /** 0–1. Draws a bar along the bottom edge and a rolling percentage. */\n  progress?: number;\n  /** Milliseconds before it closes; 0 keeps it open. Defaults depend on tone and action. */\n  timeout?: number;\n  /** \"high\" announces immediately and interrupts the screen reader. */\n  priority?: \"low\" | \"high\";\n  /** Show the close button. Swipe and Escape still work. */\n  dismissible?: boolean;\n  onClose?: () => void;\n  onRemove?: () => void;\n};\n\ntype ToastData = {\n  tone: ToastTone;\n  icon?: React.ReactNode | false;\n  action?: ToastAction;\n  progress?: number;\n  dismissible: boolean;\n  /** Increments when the same id is shown again, to replay a small nudge. */\n  bump: number;\n};\n\ntype BaseManager = Pick<ReturnType<typeof Toast.createToastManager>, \"add\" | \"update\" | \"close\">;\ntype ToastObject = Toast.Root.ToastObject<ToastData>;\n\n// A toast with somewhere to go stays long enough to reach it; errors stay longer\n// than successes; loading never times out on its own.\nfunction defaultTimeout(tone: ToastTone, action?: ToastAction) {\n  if (tone === \"loading\") return 0;\n  if (action) return 8000;\n  return tone === \"error\" || tone === \"warning\" ? 7000 : 5000;\n}\n\nconst dataKeys = [\"icon\", \"action\", \"progress\", \"dismissible\"] as const;\n\nfunction bind(manager: BaseManager, existing?: (id: string) => ToastObject | undefined) {\n  const add = (options: ToastOptions) => {\n    const { tone = \"neutral\", icon, action, progress, dismissible = true, timeout, id, ...rest } = options;\n    const prev = id ? existing?.(id) : undefined;\n    return manager.add<ToastData>({\n      ...rest,\n      id,\n      type: tone,\n      timeout: timeout ?? defaultTimeout(tone, action),\n      data: { tone, icon, action, progress, dismissible, bump: prev ? (prev.data?.bump ?? 0) + 1 : 0 },\n    });\n  };\n\n  // Only the fields you pass change. A new tone without a timeout gets that tone's default.\n  const update = (id: string, patch: Omit<ToastOptions, \"id\">) =>\n    manager.update<ToastData>(id, (prev) => {\n      const data: ToastData = { tone: \"neutral\", dismissible: true, bump: 0, ...prev.data };\n      for (const key of dataKeys) if (Object.hasOwn(patch, key)) Object.assign(data, { [key]: patch[key] });\n      const tone = patch.tone ?? data.tone;\n      const toneChanged = tone !== data.tone;\n      data.tone = tone;\n      return {\n        type: tone,\n        data,\n        ...(Object.hasOwn(patch, \"title\") && { title: patch.title }),\n        ...(Object.hasOwn(patch, \"description\") && { description: patch.description }),\n        ...(patch.priority && { priority: patch.priority }),\n        ...(patch.onClose && { onClose: patch.onClose }),\n        ...(patch.onRemove && { onRemove: patch.onRemove }),\n        ...(patch.timeout !== undefined ? { timeout: patch.timeout } : toneChanged && { timeout: defaultTimeout(tone, data.action) }),\n      };\n    });\n\n  return { add, update, close: (id?: string) => manager.close(id) };\n}\n\nexport type ToastApi = ReturnType<typeof bind>;\n\n/** Show, update and close toasts from inside a `ToastProvider`. */\nexport function useToast() {\n  const manager = Toast.useToastManager();\n  const find = (id: string) => (manager.toasts as ToastObject[]).find((t) => t.id === id && t.transitionStatus !== \"ending\");\n  return { ...bind(manager, find), toasts: manager.toasts as ToastObject[] };\n}\n\n/**\n * A manager you can call from anywhere, outside React included (API clients,\n * stores). Pass `manager` to `ToastProvider` so its toasts render in your Toaster.\n */\nexport function createToastManager() {\n  const manager = Toast.createToastManager();\n  return { manager, ...bind(manager) };\n}\n\nexport type ToastProviderProps = {\n  children: React.ReactNode;\n  /** How many are visible at once. Older ones fade out behind the stack. */\n  limit?: number;\n  /** Default milliseconds, used when a tone has no default of its own. */\n  timeout?: number;\n  /** From `createToastManager()`, to show toasts from outside React. */\n  manager?: ReturnType<typeof createToastManager>;\n};\n\nexport function ToastProvider({ children, limit = 3, timeout = 5000, manager }: ToastProviderProps) {\n  return (\n    <Toast.Provider limit={limit} timeout={timeout} toastManager={manager?.manager}>\n      {children}\n    </Toast.Provider>\n  );\n}\n\nexport type ToasterProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  position?: ToastPosition;\n  /** Render inside the nearest positioned ancestor instead of fixed to the window. */\n  contained?: boolean;\n  /** Where the portal mounts when not contained. Defaults to `document.body`. */\n  container?: HTMLElement | null;\n};\n\nconst place: Record<ToastPosition, string> = {\n  \"top-left\": \"top-4 left-4\",\n  \"top-center\": \"top-4 left-1/2 -translate-x-1/2\",\n  \"top-right\": \"top-4 right-4\",\n  \"bottom-left\": \"bottom-4 left-4\",\n  \"bottom-center\": \"bottom-4 left-1/2 -translate-x-1/2\",\n  \"bottom-right\": \"bottom-4 right-4\",\n};\n\n// Swipe toward the edge it lives on, or sideways toward the nearer side.\nconst swipe: Record<ToastPosition, (\"up\" | \"down\" | \"left\" | \"right\")[]> = {\n  \"top-left\": [\"up\", \"left\"],\n  \"top-center\": [\"up\", \"left\", \"right\"],\n  \"top-right\": [\"up\", \"right\"],\n  \"bottom-left\": [\"down\", \"left\"],\n  \"bottom-center\": [\"down\", \"left\", \"right\"],\n  \"bottom-right\": [\"down\", \"right\"],\n};\n\n/** Renders the stack. Put one inside `ToastProvider`, once per app. */\nexport function Toaster({ position = \"bottom-right\", contained = false, container, className, ...rest }: ToasterProps) {\n  const viewport = (\n    <Toast.Viewport\n      data-position={position}\n      className={cn(\n        \"z-(--z-toast) outline-none\",\n        contained ? \"absolute w-[min(356px,calc(100%-32px))]\" : \"fixed w-[356px] max-sm:inset-x-4 max-sm:w-auto max-sm:translate-x-0\",\n        place[position],\n        !contained && position.startsWith(\"bottom\") && \"max-sm:bottom-[max(16px,env(safe-area-inset-bottom))]\",\n        className,\n      )}\n      {...rest}\n    >\n      <ToastList position={position} />\n      <style href=\"stealth-toast\" precedence=\"default\">\n        {CSS}\n      </style>\n    </Toast.Viewport>\n  );\n  if (contained) return viewport;\n  return <Toast.Portal container={container}>{viewport}</Toast.Portal>;\n}\n\nfunction ToastList({ position }: { position: ToastPosition }) {\n  const { toasts, close } = Toast.useToastManager();\n  return (toasts as ToastObject[]).map((toast) => <ToastCard key={toast.id} toast={toast} position={position} close={close} />);\n}\n\nfunction ToastCard({ toast, position, close }: { toast: ToastObject; position: ToastPosition; close: (id: string) => void }) {\n  const reduce = useReducedMotion();\n  const data = toast.data;\n  const tone = data?.tone ?? (toast.type as ToastTone) ?? \"neutral\";\n  const progress = data?.progress;\n  const bump = data?.bump ?? 0;\n  const hasGlyph = data?.icon !== false && (data?.icon != null || tone !== \"neutral\");\n\n  return (\n    <Toast.Root\n      toast={toast}\n      swipeDirection={swipe[position]}\n      data-tone={tone}\n      data-bump={bump > 0 ? (bump % 2 ? \"a\" : \"b\") : undefined}\n      className={cn(\n        \"stealth-toast @container select-none rounded-xl border border-line-2 shadow-pop\",\n        \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n      )}\n    >\n      {/* Glyph, copy, action, close. On a narrow toast the action drops under the copy instead of squeezing it. */}\n      <Toast.Content className=\"stealth-toast-content relative grid grid-cols-[auto_minmax(0,1fr)_auto_auto] items-start overflow-hidden rounded-[11px] p-3 pr-2\">\n        <AnimatePresence initial={false}>\n          {hasGlyph && (\n            <motion.span\n              key=\"glyph\"\n              className=\"col-start-1 row-start-1 flex shrink-0 overflow-hidden\"\n              initial={{ width: 0, opacity: 0 }}\n              animate={{ width: 26, opacity: 1 }}\n              exit={{ width: 0, opacity: 0 }}\n              transition={{ duration: reduce ? 0 : 0.22, ease: ease.out }}\n            >\n              <span className=\"mt-1.5 grid size-4 place-items-center\">\n                <Glyph tone={tone} icon={data?.icon} reduce={!!reduce} />\n              </span>\n            </motion.span>\n          )}\n        </AnimatePresence>\n\n        {/* The copy swaps in place when the tone changes; the root animates to the new height. */}\n        <div className=\"relative col-start-2 row-start-1 min-w-0 py-[5px]\">\n          <AnimatePresence mode=\"popLayout\" initial={false}>\n            <motion.div\n              key={tone}\n              className=\"min-w-0\"\n              initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4, filter: \"blur(2px)\" }}\n              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              exit={reduce ? { opacity: 0, transition: { duration: 0.1 } } : { opacity: 0, y: -4, filter: \"blur(2px)\", transition: { duration: 0.12 } }}\n              transition={{ duration: 0.22, ease: ease.out }}\n            >\n              <div className=\"flex items-start gap-2\">\n                <Toast.Title className=\"min-w-0 flex-1 text-[13px] font-medium leading-[18px] tracking-[-0.005em] text-fg [overflow-wrap:anywhere]\" />\n                {/* Done is said by the tick; the percentage stays only while it matters. */}\n                {progress != null && tone !== \"success\" && (\n                  <span className=\"flex h-[18px] shrink-0 items-center text-[12px] text-fg-3 tabular\">\n                    <NumberFlow value={Math.min(1, Math.max(0, progress))} format={{ style: \"percent\", maximumFractionDigits: 0 }} animated={!reduce} />\n                  </span>\n                )}\n              </div>\n              <Toast.Description className=\"mt-0.5 text-[12.5px] leading-[18px] text-fg-2 [overflow-wrap:anywhere] empty:hidden\" />\n            </motion.div>\n          </AnimatePresence>\n        </div>\n\n        {data?.action && (\n          <Toast.Action\n            onClick={(e) => {\n              data.action?.onClick(e);\n              if (!e.defaultPrevented) close(toast.id);\n            }}\n            className={cn(\n              \"relative col-start-3 row-start-1 ml-2.5 h-7 shrink-0 rounded-md bg-fg px-2.5 text-[12px] font-medium text-frame\",\n              \"@max-[320px]:col-start-2 @max-[320px]:row-start-2 @max-[320px]:ml-0 @max-[320px]:mt-1.5 @max-[320px]:justify-self-start\",\n              \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n              \"transition-[background-color,scale] duration-150 ease-out hover:bg-fg/90 active:scale-[0.96] active:duration-75\",\n              \"before:absolute before:-inset-2 before:content-[''] pointer-fine:before:hidden\",\n            )}\n          >\n            {data.action.label}\n          </Toast.Action>\n        )}\n\n        {data?.dismissible !== false && (\n          <Toast.Close\n            aria-label=\"Dismiss notification\"\n            className={cn(\n              \"relative col-start-4 row-start-1 ml-1.5 grid size-7 shrink-0 place-items-center rounded-md text-fg-3\",\n              \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-0 focus-visible:outline-fg-3\",\n              \"transition-[background-color,color,scale] duration-150 ease-out hover:bg-hover hover:text-fg active:scale-[0.9] active:duration-75\",\n              \"before:absolute before:-inset-2 before:content-[''] pointer-fine:before:hidden\",\n            )}\n          >\n            <X size={14} />\n          </Toast.Close>\n        )}\n\n        {progress != null && (\n          <span aria-hidden className=\"absolute inset-x-0 bottom-0 h-0.5 bg-fg/[0.06]\">\n            <span\n              className={cn(\n                \"absolute inset-0 origin-left transition-[transform,background-color,opacity] duration-300 ease-out-expo\",\n                tone === \"success\" ? \"bg-success\" : tone === \"error\" ? \"bg-danger\" : \"bg-fg/60\",\n                tone !== \"loading\" && \"opacity-0 delay-500\",\n              )}\n              style={{ transform: `scaleX(${tone === \"success\" ? 1 : Math.min(1, Math.max(0, progress))})` }}\n            />\n          </span>\n        )}\n      </Toast.Content>\n    </Toast.Root>\n  );\n}\n\nfunction Glyph({ tone, icon, reduce }: { tone: ToastTone; icon?: React.ReactNode | false; reduce: boolean }) {\n  const kind = icon ? \"custom\" : tone === \"info\" || tone === \"warning\" ? tone : \"ring\";\n  return (\n    <AnimatePresence initial={false} mode=\"popLayout\">\n      <motion.span\n        key={kind}\n        className={cn(\n          \"grid size-4 place-items-center transition-colors duration-200\",\n          kind === \"custom\" && \"text-fg-2\",\n          kind === \"info\" && \"text-info\",\n          kind === \"warning\" && \"text-warning\",\n        )}\n        initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6, filter: \"blur(2px)\" }}\n        animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n        exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6, filter: \"blur(2px)\" }}\n        transition={{ duration: 0.2, ease: ease.out }}\n      >\n        {kind === \"custom\" ? icon : kind === \"info\" ? <Info /> : kind === \"warning\" ? <Warning /> : <Ring tone={tone} reduce={reduce} />}\n      </motion.span>\n    </AnimatePresence>\n  );\n}\n\n/**\n * One ring for loading, success and error. While loading, a short arc spins;\n * when the promise settles the arc glides to rest at the top and closes into a\n * full circle, then the tick or cross draws inside it.\n */\nfunction Ring({ tone, reduce }: { tone: ToastTone; reduce: boolean }) {\n  const rotate = useMotionValue(0);\n  const loading = tone === \"loading\";\n\n  useEffect(() => {\n    if (reduce) return;\n    if (loading) {\n      const from = rotate.get();\n      const spin = animate(rotate, [from, from + 360], { duration: 0.8, ease: \"linear\", repeat: Infinity });\n      return () => spin.stop();\n    }\n    const rest = animate(rotate, Math.ceil(rotate.get() / 360) * 360, { duration: 0.45, ease: ease.out });\n    return () => rest.stop();\n  }, [loading, reduce, rotate]);\n\n  const color = tone === \"success\" ? \"text-success\" : tone === \"error\" ? \"text-danger\" : \"text-fg-3\";\n  const draw = (delay: number) =>\n    reduce\n      ? { initial: { opacity: 0 }, animate: { opacity: 1 }, transition: { duration: 0.15 } }\n      : { initial: { pathLength: 0, opacity: 0 }, animate: { pathLength: 1, opacity: 1 }, transition: { duration: 0.28, ease: ease.out, delay } };\n\n  return (\n    <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden className={cn(\"transition-colors duration-300\", color)}>\n      <motion.circle\n        cx=\"8\"\n        cy=\"8\"\n        r=\"5.75\"\n        style={{ rotate: reduce ? -90 : rotate, originX: \"50%\", originY: \"50%\" }}\n        initial={{ pathLength: loading ? 0.28 : 0 }}\n        animate={reduce && loading ? { pathLength: 0.28, opacity: [1, 0.4, 1] } : { pathLength: loading ? 0.28 : 1, opacity: 1 }}\n        transition={\n          reduce && loading\n            ? { opacity: { duration: 1.6, repeat: Infinity, ease: \"easeInOut\" } }\n            : { pathLength: { duration: reduce ? 0 : 0.45, ease: ease.out } }\n        }\n      />\n      {tone === \"success\" && <motion.path d=\"m5.5 8.25 1.75 1.75 3.25-3.75\" {...draw(0.22)} />}\n      {tone === \"error\" && (\n        <>\n          <motion.path d=\"m6 6 4 4\" {...draw(0.22)} />\n          <motion.path d=\"M10 6 6 10\" {...draw(0.32)} />\n        </>\n      )}\n    </svg>\n  );\n}\n\n// Stack geometry. Base UI measures each toast and hands us --toast-index,\n// --toast-height, --toast-offset-y and the live swipe offsets; everything\n// else is derived here so the stack, the hover spread, the entrance and the\n// swipe exit share one transform.\nconst CSS = `\n.stealth-toast {\n  --dir: 1;\n  --gap: 8px;\n  --peek: 8px;\n  --scale: max(0, 1 - var(--toast-index) * 0.05);\n  --height: var(--toast-frontmost-height, var(--toast-height));\n  --y: calc(var(--dir) * -1 * (var(--toast-index) * var(--peek) + (1 - var(--scale)) * var(--height)));\n  --s: var(--scale);\n  position: absolute;\n  inset-inline: 0;\n  bottom: 0;\n  height: var(--height);\n  z-index: calc(10 - var(--toast-index));\n  transform-origin: 50% 100%;\n  transform: translateX(var(--toast-swipe-movement-x)) translateY(calc(var(--y) + var(--toast-swipe-movement-y))) scale(var(--s));\n  background-color: color-mix(in oklab, var(--raised), var(--page) calc(min(var(--toast-index), 3) * 24%));\n  transition:\n    transform 420ms var(--ease-out-expo),\n    opacity 240ms var(--ease-out-expo),\n    height 260ms var(--ease-out-expo),\n    background-color 240ms var(--ease-out-quart);\n  cursor: default;\n  touch-action: none;\n}\n[data-position^=\"top\"] .stealth-toast { --dir: -1; top: 0; bottom: auto; transform-origin: 50% 0; }\n.stealth-toast[data-expanded] {\n  --y: calc(var(--dir) * -1 * (var(--toast-offset-y) + var(--toast-index) * var(--gap)));\n  --s: 1;\n  height: var(--toast-height);\n  background-color: var(--raised);\n}\n.stealth-toast[data-starting-style] { transform: translateY(calc(var(--dir) * (100% + 24px))); opacity: 0; }\n.stealth-toast[data-ending-style] { opacity: 0; transition-duration: 200ms, 160ms, 200ms, 200ms; }\n.stealth-toast[data-ending-style]:not([data-swipe-direction]) { transform: translateY(calc(var(--y) + var(--dir) * 10px)) scale(calc(var(--s) * 0.97)); }\n.stealth-toast[data-ending-style][data-swipe-direction=\"right\"] { transform: translateX(calc(var(--toast-swipe-movement-x) + 110%)) translateY(var(--y)); }\n.stealth-toast[data-ending-style][data-swipe-direction=\"left\"] { transform: translateX(calc(var(--toast-swipe-movement-x) - 110%)) translateY(var(--y)); }\n.stealth-toast[data-ending-style][data-swipe-direction=\"down\"] { transform: translateY(calc(var(--y) + var(--toast-swipe-movement-y) + 110%)); }\n.stealth-toast[data-ending-style][data-swipe-direction=\"up\"] { transform: translateY(calc(var(--y) + var(--toast-swipe-movement-y) - 110%)); }\n.stealth-toast[data-limited] { opacity: 0; pointer-events: none; }\n.stealth-toast[data-swiping] { transition-property: opacity, height, background-color; }\n.stealth-toast[data-bump=\"a\"] { animation: stealth-toast-bump-a 320ms var(--ease-out-expo); }\n.stealth-toast[data-bump=\"b\"] { animation: stealth-toast-bump-b 320ms var(--ease-out-expo); }\n@keyframes stealth-toast-bump-a { 40% { scale: 1.025; } }\n@keyframes stealth-toast-bump-b { 40% { scale: 1.025; } }\n/* Keeps the stack expanded while the pointer crosses the gaps between toasts. */\n.stealth-toast::after { content: \"\"; position: absolute; inset-inline: 0; top: 100%; height: calc(var(--gap) + 1px); }\n[data-position^=\"top\"] .stealth-toast::after { top: auto; bottom: 100%; }\n.stealth-toast-content { transition: opacity 200ms var(--ease-out-quart); }\n.stealth-toast-content[data-behind]:not([data-expanded]) { opacity: 0; }\n@media (prefers-reduced-motion: reduce) {\n  .stealth-toast[data-starting-style],\n  .stealth-toast[data-ending-style]:not([data-swipe-direction]) { transform: translateY(var(--y)) scale(var(--s)); }\n}\n`;\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/toast"
}
