{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "toast-stack",
  "type": "registry:component",
  "title": "Toast Stack",
  "description": "Notifications stacked like cards: they fan out on hover or focus, pause while you read them, close on their own and are announced with aria-live.",
  "author": "Marcelo Treviño Velázquez <https://furin.dev>",
  "dependencies": [],
  "categories": [
    "Navigation"
  ],
  "docs": "Installed in components/furin/toast-stack/. Docs, live controls and props table: https://furin.dev/components/toast-stack/",
  "files": [
    {
      "path": "registry/toast-stack/ToastStack.tsx",
      "content": "\"use client\";\nimport { useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from \"react\";\nimport type { CSSProperties, Ref } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport \"./ToastStack.css\";\n\nexport type ToastKind = \"info\" | \"success\" | \"warning\" | \"error\";\n\nexport interface ToastData {\n  /** Identificador (si no lo pones, se genera uno). */\n  id?: string;\n  /** Título de la notificación. */\n  title: string;\n  /** Texto de apoyo. */\n  description?: string;\n  /** Tipo: define el ícono y el color. */\n  kind?: ToastKind;\n  /** Milisegundos antes de cerrarse sola (0 = no se cierra). */\n  duration?: number;\n  /** Botón de acción opcional. */\n  action?: { label: string; onClick: () => void };\n}\n\nexport interface ToastStackHandle {\n  /** Agrega una notificación y devuelve su id. */\n  push: (toast: ToastData) => string;\n  /** Cierra una notificación por id. */\n  dismiss: (id: string) => void;\n  /** Cierra todas. */\n  clear: () => void;\n}\n\nexport interface ToastStackProps {\n  /** Referencia con push, dismiss y clear para lanzar notificaciones desde tu código. */\n  ref?: Ref<ToastStackHandle>;\n  /** Notificaciones de ejemplo que lanzan el botón y el modo autoplay (y las tres primeras son las de partida). */\n  samples?: ToastData[];\n  /** Notificaciones que ya están al montar. */\n  defaultToasts?: ToastData[];\n  /** Esquina donde se apilan. */\n  position?: \"bottom-right\" | \"bottom-left\" | \"bottom-center\" | \"top-right\" | \"top-left\" | \"top-center\";\n  /** Milisegundos por defecto antes de cerrarse (0 = no se cierran). */\n  duration?: number;\n  /** Máximo de notificaciones a la vez. */\n  max?: number;\n  /** Se ancla a la ventana en vez de a su contenedor. */\n  fixed?: boolean;\n  /** Muestra un botón para lanzar notificaciones de ejemplo. */\n  trigger?: boolean;\n  /** Segundos entre notificaciones de ejemplo automáticas (0 = apagado). */\n  autoplay?: number;\n  /** Etiqueta accesible de la región. */\n  label?: string;\n  /** Texto del botón de ejemplo (con trigger). */\n  triggerLabel?: string;\n  /** Texto de pista bajo el botón de ejemplo (con trigger). */\n  triggerHint?: string;\n  /** Clase extra para el contenedor. */\n  className?: string;\n  /** Estilos extra para el contenedor. */\n  style?: CSSProperties;\n}\n\ninterface Item extends ToastData { id: string; salida?: boolean; nuevo?: boolean; ms: number }\n\nconst MUESTRAS: ToastData[] = [\n  { kind: \"success\", title: \"Changes saved\", description: \"Version 2.4 is now live.\" },\n  { kind: \"info\", title: \"New comment\", description: \"Ana replied in Product design.\" },\n  { kind: \"warning\", title: \"Storage at 90%\", description: \"Free up space to keep uploading files.\" },\n  { kind: \"error\", title: \"Sync failed\", description: \"Retrying in a few seconds.\" },\n  { kind: \"success\", title: \"Invitation sent\", description: \"We let luis@studio.com know.\" },\n];\n\nlet contador = 0;\nconst GAP = 10;\n\nfunction Icono({ kind }: { kind: ToastKind }) {\n  const p = { fill: \"none\", stroke: \"currentColor\", strokeWidth: 1.7, strokeLinecap: \"round\", strokeLinejoin: \"round\" } as const;\n  return (\n    <svg viewBox=\"0 0 20 20\" aria-hidden=\"true\">\n      {kind === \"success\" ? <path {...p} d=\"m5.4 10.4 3 3 6.2-6.8\" /> : null}\n      {kind === \"info\" ? <path {...p} d=\"M10 9v5M10 6.1v.1\" /> : null}\n      {kind === \"warning\" ? <path {...p} d=\"M10 5.6v5M10 13.9v.1\" /> : null}\n      {kind === \"error\" ? <path {...p} d=\"m6.8 6.8 6.4 6.4M13.2 6.8l-6.4 6.4\" /> : null}\n    </svg>\n  );\n}\n\n/** Notificaciones apiladas como cartas: se despliegan al pasar el cursor o enfocar, se pausan mientras las lees y se cierran solas. */\nexport function ToastStack({\n  ref,\n  samples = MUESTRAS,\n  defaultToasts = samples.slice(0, 3).map((t) => ({ ...t, duration: 9000 })),\n  position = \"bottom-right\",\n  duration = 5000,\n  max = 5,\n  fixed = false,\n  trigger = false,\n  autoplay = 0,\n  label = \"Notifications\",\n  triggerLabel = \"Show notification\",\n  triggerHint = \"Hover over the stack to fan it out.\",\n  className = \"\",\n  style,\n}: ToastStackProps) {\n  const crear = (t: ToastData): Item => ({ ...t, id: t.id ?? `t${++contador}`, ms: t.duration ?? duration, nuevo: true });\n  const [items, setItems] = useState<Item[]>(() => defaultToasts.map(crear));\n  const [abierto, setAbierto] = useState(false);\n  const [alturas, setAlturas] = useState<Record<string, number>>({});\n  const [montado, setMontado] = useState(false);\n  const muestras = useRef(samples);\n  muestras.current = samples;\n  const pausa = useRef({ cursor: false, foco: false });\n  const refs = useRef<Record<string, HTMLDivElement | null>>({});\n  const ultima = useRef<Record<string, { y: number; s: number }>>({});\n  const tocado = useRef(false);\n  const muestra = useRef(3);\n  const live = useRef({ max, duration });\n  live.current = { max, duration };\n  const arriba = position.startsWith(\"top\");\n\n  useEffect(() => setMontado(true), []);\n\n  const quitar = useCallback((id: string) => {\n    setItems((l) => (l.some((t) => t.id === id && !t.salida) ? l.map((t) => (t.id === id ? { ...t, salida: true } : t)) : l));\n    window.setTimeout(() => setItems((l) => l.filter((t) => t.id !== id)), 360);\n  }, []);\n\n  const empujar = useCallback((t: ToastData) => {\n    const it: Item = { ...t, id: t.id ?? `t${++contador}`, ms: t.duration ?? live.current.duration, nuevo: true };\n    setItems((l) => {\n      const vivas = l.filter((x) => !x.salida);\n      const sobran = vivas.length + 1 - live.current.max;\n      const salientes = new Set(sobran > 0 ? vivas.slice(0, sobran).map((x) => x.id) : []);\n      return [...l.map((x) => (salientes.has(x.id) ? { ...x, salida: true } : x)), it];\n    });\n    return it.id;\n  }, []);\n\n  useImperativeHandle(ref, () => ({ push: empujar, dismiss: quitar, clear: () => setItems((l) => l.map((t) => ({ ...t, salida: true }))) }), [empujar, quitar]);\n\n  // Las nuevas entran un fotograma después, para que la transición de entrada se vea.\n  useEffect(() => {\n    if (!items.some((t) => t.nuevo)) return;\n    const id = requestAnimationFrame(() => requestAnimationFrame(() => setItems((l) => l.map((t) => (t.nuevo ? { ...t, nuevo: false } : t)))));\n    return () => cancelAnimationFrame(id);\n  }, [items]);\n\n  // Mide la altura real de cada notificación.\n  useLayoutEffect(() => {\n    const ro = new ResizeObserver(() => {\n      setAlturas((prev) => {\n        const sig: Record<string, number> = {};\n        let cambio = false;\n        for (const [id, el] of Object.entries(refs.current)) {\n          if (!el) continue;\n          sig[id] = el.offsetHeight;\n          if (prev[id] !== sig[id]) cambio = true;\n        }\n        return cambio || Object.keys(prev).length !== Object.keys(sig).length ? sig : prev;\n      });\n    });\n    for (const el of Object.values(refs.current)) if (el) ro.observe(el);\n    return () => ro.disconnect();\n  }, [items.length]);\n\n  useEffect(() => {\n    if (!autoplay) return;\n    const id = window.setInterval(() => {\n      if (tocado.current || document.hidden) return;\n      const m = muestras.current[muestra.current++ % muestras.current.length];\n      empujar({ ...m, duration: 7000 });\n    }, autoplay * 1000);\n    return () => window.clearInterval(id);\n  }, [autoplay, empujar]);\n\n  const sync = () => setAbierto(pausa.current.cursor || pausa.current.foco);\n  const pausado = abierto;\n\n  // Orden visual: la más nueva al frente (índice 0).\n  const vivas = items.filter((t) => !t.salida);\n  const orden = [...vivas].reverse();\n  const frente = orden[0] ? alturas[orden[0].id] ?? 72 : 0;\n  const total = orden.reduce((a, t, i) => a + (alturas[t.id] ?? 72) + (i ? GAP : 0), 0);\n  const alto = abierto ? total : frente + Math.min(orden.length - 1, 2) * 12;\n  const dir = arriba ? 1 : -1;\n\n  const nodo = (\n    <div\n      className={`furin-toast-stack__region furin-toast-stack__region--${position}${fixed ? \" is-fija\" : \"\"}`}\n      role=\"region\"\n      aria-label={label}\n      tabIndex={-1}\n    >\n      <div\n        className={`furin-toast-stack__pila${abierto ? \" is-abierta\" : \"\"}`}\n        style={{ height: alto, [\"--furin-toast-frente\" as string]: `${frente}px` } as CSSProperties}\n        onPointerEnter={() => { pausa.current.cursor = true; tocado.current = true; sync(); }}\n        onPointerLeave={() => { pausa.current.cursor = false; sync(); }}\n        onFocus={() => { pausa.current.foco = true; sync(); }}\n        onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node)) { pausa.current.foco = false; sync(); } }}\n        aria-live=\"polite\"\n        aria-relevant=\"additions\"\n      >\n        {items.map((t) => {\n          const i = orden.findIndex((x) => x.id === t.id);\n          let y: number, s: number, op: number;\n          if (t.salida || i < 0) {\n            const u = ultima.current[t.id] ?? { y: 0, s: 1 };\n            y = u.y; s = u.s; op = 0;\n          } else if (abierto) {\n            const antes = orden.slice(0, i).reduce((a, x) => a + (alturas[x.id] ?? 72) + GAP, 0);\n            y = antes * dir; s = 1; op = 1;\n          } else {\n            y = Math.min(i, 3) * 12 * dir; s = 1 - Math.min(i, 3) * 0.055; op = i > 2 ? 0 : 1;\n          }\n          if (!t.salida && i >= 0) ultima.current[t.id] = { y, s };\n          const entrando = t.nuevo ? 1 : 0;\n          const tapada = !abierto && i > 0;\n          return (\n            <div\n              key={t.id}\n              ref={(el) => { refs.current[t.id] = el; }}\n              role={t.kind === \"error\" ? \"alert\" : undefined}\n              className={`furin-toast-stack__toast furin-toast-stack__toast--${t.kind ?? \"info\"}${t.salida ? \" is-saliendo\" : \"\"}${entrando ? \" is-nuevo\" : \"\"}${tapada ? \" is-tapada\" : \"\"}`}\n              style={{\n                transform: `translate3d(${t.salida ? \"28px\" : \"0\"}, ${entrando ? y + 26 * -dir : y}px, 0) scale(${entrando ? 0.94 : s})`,\n                opacity: entrando ? 0 : op,\n                zIndex: 100 - Math.max(i, 0),\n                [\"--furin-toast-ms\" as string]: `${t.ms}ms`,\n              } as CSSProperties}\n            >\n              <span className=\"furin-toast-stack__icono\"><Icono kind={t.kind ?? \"info\"} /></span>\n              <div className=\"furin-toast-stack__cuerpo\">\n                <p className=\"furin-toast-stack__titulo\">{t.title}</p>\n                {t.description ? <p className=\"furin-toast-stack__desc\">{t.description}</p> : null}\n                {t.action ? (\n                  <button type=\"button\" className=\"furin-toast-stack__accion\" onClick={() => { t.action?.onClick(); quitar(t.id); }}>{t.action.label}</button>\n                ) : null}\n              </div>\n              <button type=\"button\" className=\"furin-toast-stack__cerrar\" aria-label={`Dismiss: ${t.title}`} onClick={() => quitar(t.id)} tabIndex={tapada ? -1 : 0}>\n                <svg viewBox=\"0 0 16 16\" aria-hidden=\"true\"><path d=\"m4.5 4.5 7 7M11.5 4.5l-7 7\" /></svg>\n              </button>\n              {t.ms > 0 ? (\n                <span className=\"furin-toast-stack__barra\" aria-hidden=\"true\">\n                  <i\n                    style={{ animationPlayState: pausado ? \"paused\" : \"running\" }}\n                    onAnimationEnd={() => quitar(t.id)}\n                  />\n                </span>\n              ) : null}\n            </div>\n          );\n        })}\n      </div>\n    </div>\n  );\n\n  const lanzar = () => {\n    tocado.current = true;\n    const m = muestras.current[muestra.current++ % muestras.current.length];\n    empujar({ ...m, duration: 6000 });\n  };\n\n  return (\n    <div className={`furin-toast-stack ${className}`} style={style}>\n      {trigger ? (\n        <div className=\"furin-toast-stack__centro\">\n          <button type=\"button\" className=\"furin-toast-stack__lanzar\" onClick={lanzar}>{triggerLabel}</button>\n          <p className=\"furin-toast-stack__pista\">{triggerHint}</p>\n        </div>\n      ) : null}\n      {fixed ? (montado ? createPortal(nodo, document.body) : null) : nodo}\n    </div>\n  );\n}\n\nexport default ToastStack;\n",
      "type": "registry:component",
      "target": "components/furin/toast-stack/ToastStack.tsx"
    },
    {
      "path": "registry/toast-stack/ToastStack.css",
      "content": ".furin-toast-stack {\n  position: relative;\n  width: 100%;\n  height: 100%;\n  min-height: 220px;\n  overflow: hidden;\n  font-family: Geist, system-ui, -apple-system, sans-serif;\n  color: #f2f3f5;\n}\n.furin-toast-stack__centro { position: absolute; inset: 0; display: grid; place-content: center; justify-items: center; gap: 14px; padding-bottom: 60px; }\n.furin-toast-stack__lanzar { appearance: none; padding: 11px 18px; border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.14); background: rgba(255, 255, 255, 0.06); font: 500 14px/1 Geist, system-ui, sans-serif; color: #fff; cursor: pointer; transition: background-color 0.2s, transform 0.2s; }\n.furin-toast-stack__lanzar:hover { background: rgba(255, 255, 255, 0.1); }\n.furin-toast-stack__lanzar:active { transform: scale(0.97); }\n.furin-toast-stack__lanzar:focus-visible { outline: 2px solid rgba(255, 255, 255, 0.85); outline-offset: 2px; }\n.furin-toast-stack__pista { margin: 0; font: 500 11px/1 \"JetBrains Mono\", ui-monospace, \"SF Mono\", Menlo, monospace; letter-spacing: 0.1em; text-transform: uppercase; color: rgba(255, 255, 255, 0.34); }\n\n/* Región anclada a una esquina */\n.furin-toast-stack__region { position: absolute; z-index: 10; width: min(320px, calc(100% - 28px)); padding: 0; pointer-events: none; }\n.furin-toast-stack__region.is-fija { position: fixed; z-index: 2147483000; }\n.furin-toast-stack__region--bottom-right { right: 14px; bottom: 14px; }\n.furin-toast-stack__region--bottom-left { left: 14px; bottom: 14px; }\n.furin-toast-stack__region--bottom-center { left: 50%; bottom: 14px; transform: translateX(-50%); }\n.furin-toast-stack__region--top-right { right: 14px; top: 14px; }\n.furin-toast-stack__region--top-left { left: 14px; top: 14px; }\n.furin-toast-stack__region--top-center { left: 50%; top: 14px; transform: translateX(-50%); }\n.furin-toast-stack__pila { position: relative; width: 100%; pointer-events: auto; transition: height 0.45s cubic-bezier(0.22, 1.1, 0.36, 1); }\n/* El área de la pila se extiende un poco para que el cursor no la \"pierda\" entre notificaciones. */\n.furin-toast-stack__pila::before { content: \"\"; position: absolute; inset: -10px; }\n\n.furin-toast-stack__toast {\n  position: absolute;\n  left: 0; right: 0;\n  display: flex;\n  align-items: flex-start;\n  gap: 12px;\n  padding: 13px 14px 14px;\n  border-radius: 14px;\n  background: linear-gradient(180deg, rgba(36, 38, 45, 0.9), rgba(24, 25, 31, 0.92));\n  -webkit-backdrop-filter: blur(18px) saturate(1.4);\n  backdrop-filter: blur(18px) saturate(1.4);\n  box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.07), 0 18px 36px -14px rgba(0, 0, 0, 0.7), 0 4px 10px -4px rgba(0, 0, 0, 0.4);\n  overflow: hidden;\n  bottom: 0;\n  transform-origin: 50% 100%;\n  transition: transform 0.5s cubic-bezier(0.22, 1.15, 0.36, 1), opacity 0.3s ease, height 0.3s ease;\n  will-change: transform, opacity;\n}\n.furin-toast-stack__region--top-right .furin-toast-stack__toast, .furin-toast-stack__region--top-left .furin-toast-stack__toast, .furin-toast-stack__region--top-center .furin-toast-stack__toast { bottom: auto; top: 0; transform-origin: 50% 0; }\n.furin-toast-stack__toast.is-nuevo { transition: none; }\n.furin-toast-stack__toast.is-saliendo { transition: transform 0.34s cubic-bezier(0.4, 0, 1, 1), opacity 0.28s ease; pointer-events: none; }\n/* Las que quedan detrás de la primera se ven como el borde de una carta: se oculta su contenido y se iguala su altura. */\n.furin-toast-stack__toast.is-tapada { height: var(--furin-toast-frente); }\n.furin-toast-stack__toast.is-tapada > * { opacity: 0; transition: opacity 0.15s; }\n.furin-toast-stack__toast > * { transition: opacity 0.3s 0.08s; }\n\n.furin-toast-stack__icono { display: grid; place-items: center; width: 24px; height: 24px; margin-top: 1px; border-radius: 50%; flex: none; color: var(--c); background: color-mix(in srgb, var(--c) 16%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--c) 40%, transparent); }\n.furin-toast-stack__icono svg { width: 15px; height: 15px; display: block; }\n.furin-toast-stack__toast--info { --c: #8ea2ff; }\n.furin-toast-stack__toast--success { --c: #6ee0a6; }\n.furin-toast-stack__toast--warning { --c: #ffc15e; }\n.furin-toast-stack__toast--error { --c: #ff7a85; }\n\n.furin-toast-stack__cuerpo { flex: 1; min-width: 0; display: grid; gap: 3px; }\n.furin-toast-stack__titulo { margin: 0; font-size: 14px; font-weight: 600; letter-spacing: -0.01em; color: #fff; }\n.furin-toast-stack__desc { margin: 0; font-size: 13px; line-height: 1.4; color: rgba(255, 255, 255, 0.6); }\n.furin-toast-stack__accion { justify-self: start; margin-top: 6px; appearance: none; padding: 5px 10px; border-radius: 8px; border: 1px solid rgba(255, 255, 255, 0.16); background: rgba(255, 255, 255, 0.06); font: 500 12.5px/1 Geist, system-ui, sans-serif; color: #fff; cursor: pointer; }\n.furin-toast-stack__accion:hover { background: rgba(255, 255, 255, 0.12); }\n.furin-toast-stack__cerrar { display: grid; place-items: center; width: 22px; height: 22px; margin: -2px -4px 0 0; padding: 0; appearance: none; border: 0; border-radius: 7px; background: none; color: rgba(255, 255, 255, 0.4); cursor: pointer; flex: none; transition: background-color 0.2s, color 0.2s; }\n.furin-toast-stack__cerrar svg { width: 13px; height: 13px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; }\n.furin-toast-stack__cerrar:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }\n.furin-toast-stack__cerrar:focus-visible, .furin-toast-stack__accion:focus-visible { outline: 2px solid rgba(255, 255, 255, 0.85); outline-offset: 1px; }\n\n/* Barra de tiempo restante: es una animación CSS que se pausa con el cursor y, al terminar, cierra la notificación. */\n.furin-toast-stack__barra { position: absolute; left: 0; right: 0; bottom: 0; height: 2px; background: rgba(255, 255, 255, 0.05); }\n.furin-toast-stack__barra i { display: block; height: 100%; transform-origin: left center; background: var(--c); opacity: 0.8; animation: furin-toast-stack-barra var(--furin-toast-ms) linear forwards; }\n@keyframes furin-toast-stack-barra { from { transform: scaleX(1); } to { transform: scaleX(0); } }\n@media (prefers-reduced-motion: reduce) {\n  .furin-toast-stack__toast, .furin-toast-stack__pila, .furin-toast-stack__toast > * { transition: none !important; }\n  /* La barra se oculta, pero la cuenta regresiva sigue corriendo: es lo que cierra la notificación. */\n  .furin-toast-stack__barra { opacity: 0; }\n}\n",
      "type": "registry:file",
      "target": "components/furin/toast-stack/ToastStack.css"
    }
  ]
}
