{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"notification-stack-clean","type":"registry:ui","title":"Notification Stack (Clean)","description":"Notice cards stacked behind each other – dismiss the front one to reveal the next","dependencies":[],"files":[{"path":"registry/notification-stack-clean/notification-stack-clean.tsx","type":"registry:ui","content":"\"use client\";\n\nimport { useLayoutEffect, useRef, useState } from \"react\";\n\n/*\n * Notification Stack (clean style) – notices stacked behind each other, in a single self-contained file.\n * One white card per notice with hairline shadows instead of borders, Inter at 13/15px, quiet line art.\n * Only the front card can be read; the others peek out below it. Dismissing the front card slides it away and\n * the next one moves up – one notice at a time.\n * The styles are injected below, so no extra files are needed.\n */\n\nexport type NoticePattern = \"orbit\" | \"grid\" | \"rays\";\n\nexport type Notice = {\n  id: string;\n  /** Small label in the pill, e.g. \"Notice\" */\n  badge?: string;\n  title: string;\n  text: string;\n  action?: { label: string; href?: string; onClick?: () => void; external?: boolean };\n  /** Line art in the top-right corner */\n  pattern?: NoticePattern;\n};\n\nexport type NotificationStackProps = {\n  notices: Notice[];\n  /** Called after a notice was dismissed */\n  onDismiss?: (notice: Notice) => void;\n  className?: string;\n};\n\nconst PEEK = 12; // how far each card behind peeks out below the one in front, in px\n\nexport function NotificationStack({ notices, onDismiss, className }: NotificationStackProps) {\n  const [items, setItems] = useState(notices);\n  const [leaving, setLeaving] = useState<Set<string>>(new Set());\n  // New notices from the parent join the stack; dismissed ones stay gone\n  const removed = useRef(new Set<string>());\n  const [given, setGiven] = useState(notices);\n  if (notices !== given) {\n    setGiven(notices);\n    setItems(notices.filter((n) => !removed.current.has(n.id)));\n  }\n  const [heights, setHeights] = useState<Record<string, number>>({});\n  const refs = useRef(new Map<string, HTMLElement>());\n\n  const visible = items.filter((n) => !leaving.has(n.id));\n  const behind = Math.min(visible.length - 1, 2);\n\n  // The front card's height sets the height of the stack\n  useLayoutEffect(() => {\n    const measure = () => {\n      const next: Record<string, number> = {};\n      // The full height, also for a card behind that is cut to the front card's height\n      refs.current.forEach((el, id) => (next[id] = el.scrollHeight + el.offsetHeight - el.clientHeight));\n      setHeights(next);\n    };\n    measure();\n    const ro = new ResizeObserver(measure);\n    refs.current.forEach((el) => ro.observe(el));\n    return () => ro.disconnect();\n  }, [items]);\n\n  const dismiss = (notice: Notice) => {\n    setLeaving((s) => new Set(s).add(notice.id));\n    onDismiss?.(notice);\n    window.setTimeout(() => {\n      removed.current.add(notice.id);\n      setItems((list) => list.filter((n) => n.id !== notice.id));\n      setLeaving((s) => {\n        const next = new Set(s);\n        next.delete(notice.id);\n        return next;\n      });\n    }, 320);\n  };\n\n  const front = visible[0] ? heights[visible[0].id] ?? 0 : 0;\n\n  return (\n    <>\n      <style href=\"ui-notification-stack-clean\" precedence=\"default\">\n        {css}\n      </style>\n      <section\n        className={[\"ui-nsc\", className].filter(Boolean).join(\" \")}\n        aria-label=\"Notifications\"\n        style={{ height: visible.length ? front + behind * PEEK : 0 }}\n      >\n        {items.map((notice) => {\n          const i = visible.findIndex((n) => n.id === notice.id);\n          const gone = i === -1;\n          const depth = Math.max(0, i);\n          // Only the cards that can show are rendered – a long list stays light\n          if (!gone && depth > 3) return null;\n          const isBehind = !gone && depth > 0;\n          // Cards behind are lined up on the front card's bottom edge, a little lower and smaller each\n          // A card behind that is taller than the front one is cut to its height, so it never peeks out above\n          const y = gone || depth === 0 ? 0 : front - Math.min(heights[notice.id] ?? front, front) + Math.min(depth, 2) * PEEK;\n          const scale = gone ? 1 : 1 - Math.min(depth, 2) * 0.05;\n          return (\n            <article\n              key={notice.id}\n              ref={(el) => {\n                if (el) refs.current.set(notice.id, el);\n                else refs.current.delete(notice.id);\n              }}\n              className=\"ui-nsc__card\"\n              data-depth={gone ? undefined : depth}\n              data-leaving={gone || undefined}\n              aria-labelledby={`${notice.id}-title`}\n              aria-hidden={isBehind || undefined}\n              inert={isBehind || gone}\n              style={{\n                zIndex: gone ? 10 : 10 - depth,\n                transform: gone ? undefined : `translateY(${y}px) scale(${scale})`,\n                opacity: depth > 2 ? 0 : undefined,\n                maxHeight: isBehind && front ? front : undefined,\n              }}\n            >\n              <Pattern kind={notice.pattern ?? \"orbit\"} />\n              <div className=\"ui-nsc__body\">\n                <div className=\"ui-nsc__top\">\n                  {notice.badge ? (\n                    <span className=\"ui-nsc__badge\" dir=\"auto\" title={notice.badge}>\n                      {notice.badge}\n                    </span>\n                  ) : (\n                    <span />\n                  )}\n                  <button\n                    type=\"button\"\n                    className=\"ui-nsc__close\"\n                    aria-label={`Dismiss ${notice.title}`}\n                    onClick={(e) => {\n                      e.stopPropagation();\n                      dismiss(notice);\n                    }}\n                  >\n                    <CloseIcon />\n                  </button>\n                </div>\n                <h3 id={`${notice.id}-title`} className=\"ui-nsc__title\" dir=\"auto\">\n                  {notice.title}\n                </h3>\n                <p className=\"ui-nsc__text\" dir=\"auto\">\n                  {notice.text}\n                </p>\n                {notice.action &&\n                  (notice.action.href ? (\n                    <a\n                      className=\"ui-nsc__btn\"\n                      dir=\"auto\"\n                      href={notice.action.href}\n                      onClick={notice.action.onClick}\n                      {...(notice.action.external ? { target: \"_blank\", rel: \"noreferrer\" } : {})}\n                    >\n                      {notice.action.label}\n                      {notice.action.external && <ArrowIcon />}\n                    </a>\n                  ) : (\n                    <button type=\"button\" className=\"ui-nsc__btn\" dir=\"auto\" onClick={notice.action.onClick}>\n                      {notice.action.label}\n                    </button>\n                  ))}\n              </div>\n            </article>\n          );\n        })}\n      </section>\n    </>\n  );\n}\n\n/** Quiet line art for the top-right corner – fades out towards the text */\nfunction Pattern({ kind }: { kind: NoticePattern }) {\n  return (\n    <svg className=\"ui-nsc__pattern\" viewBox=\"0 0 220 160\" fill=\"none\" aria-hidden=\"true\">\n      {kind === \"orbit\" && (\n        <>\n          <circle cx=\"190\" cy=\"10\" r=\"46\" />\n          <circle cx=\"190\" cy=\"10\" r=\"86\" />\n          <circle cx=\"190\" cy=\"10\" r=\"128\" />\n          <path d=\"M60 160 220 0M0 58h220M150 0v160\" />\n          <circle cx=\"150\" cy=\"58\" r=\"3.5\" className=\"ui-nsc__dot\" />\n        </>\n      )}\n      {kind === \"grid\" && (\n        <>\n          {[30, 60, 90, 120, 150, 180, 210].map((x) => (\n            <path key={`x${x}`} d={`M${x} 0v160`} />\n          ))}\n          {[20, 50, 80, 110, 140].map((y) => (\n            <path key={`y${y}`} d={`M0 ${y}h220`} />\n          ))}\n          <rect x=\"150\" y=\"50\" width=\"30\" height=\"30\" className=\"ui-nsc__fill\" />\n          <circle cx=\"180\" cy=\"50\" r=\"3.5\" className=\"ui-nsc__dot\" />\n        </>\n      )}\n      {kind === \"rays\" && (\n        <>\n          {[0, 1, 2, 3, 4, 5, 6, 7].map((i) => (\n            <path key={i} d={`M210 6 L${210 - Math.cos((i * Math.PI) / 16) * 230} ${6 + Math.sin((i * Math.PI) / 16) * 230}`} />\n          ))}\n          <path d=\"M120 6a90 90 0 0 0 90 90\" />\n          <circle cx=\"146\" cy=\"70\" r=\"3.5\" className=\"ui-nsc__dot\" />\n        </>\n      )}\n    </svg>\n  );\n}\n\nconst CloseIcon = () => (\n  <svg viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.1\" strokeLinecap=\"round\" aria-hidden=\"true\">\n    <path d=\"m4 4 8 8M12 4l-8 8\" />\n  </svg>\n);\n\nconst ArrowIcon = () => (\n  <svg viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.1\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\">\n    <path d=\"M5 11 11 5M6 5h5v5\" />\n  </svg>\n);\n\n/* ---------- Styles ---------- */\n\nconst css = /* css */ `\n/* Colors and motion as variables – override them on .ui-nsc */\n.ui-nsc {\n  --ui-nsc-card: var(--aui-surface, #ffffff);\n  --ui-nsc-strong: var(--aui-ink, #000000);\n  --ui-nsc-ink: var(--aui-ink, rgba(0, 0, 0, 0.88));\n  --ui-nsc-text: var(--aui-muted, rgba(0, 0, 0, 0.61));\n  --ui-nsc-icon: var(--aui-muted, rgba(0, 0, 0, 0.447));\n  --ui-nsc-line: var(--aui-border, rgba(0, 0, 0, 0.06));\n  --ui-nsc-wash: var(--aui-subtle, rgba(0, 0, 0, 0.03));\n  --ui-nsc-fill: var(--aui-subtle, rgba(0, 0, 0, 0.06));\n  --ui-nsc-art: rgba(0, 0, 0, 0.07);\n  --ui-nsc-accent: rgba(0, 96, 226, 0.765);\n  --ui-nsc-accent-bg: rgba(0, 96, 226, 0.05);\n  --ui-nsc-focus: var(--aui-focus, rgba(0, 96, 226, 0.7));\n  /* Strokes are shadows: a 0.5px hairline plus a soft lift */\n  --ui-nsc-shadow: 0 0 0 0.5px var(--aui-border, rgba(0, 0, 0, 0.06)), 0 1px 1px -1px rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.05);\n  --ui-nsc-shadow-hover: 0 0 0 0.5px var(--aui-border-strong, rgba(0, 0, 0, 0.1)), 0 1px 1px -1px rgba(0, 0, 0, 0.12), 0 1px 3px 0 rgba(0, 0, 0, 0.07);\n  --ui-nsc-ease: var(--aui-ease, cubic-bezier(0.22, 1, 0.36, 1));\n  position: relative;\n  box-sizing: border-box;\n  container-type: inline-size;\n  width: 100%;\n  max-width: 380px;\n  font-family: var(--aui-font, \"Inter\", \"InterVariable\", ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif);\n  font-size: 13px;\n  font-weight: 400;\n  line-height: 20px;\n  color: var(--ui-nsc-ink);\n  text-align: left;\n  -webkit-font-smoothing: antialiased;\n  transition: height 460ms var(--ui-nsc-ease);\n}\n.ui-nsc *, .ui-nsc *::before, .ui-nsc *::after { box-sizing: border-box; }\n:where(.ui-nsc) button { margin: 0; padding: 0; border: 0; background: none; font: inherit; color: inherit; cursor: pointer; -webkit-tap-highlight-color: transparent; }\n:where(.ui-nsc) svg { flex: none; }\n.ui-nsc button:focus-visible,\n.ui-nsc a:focus-visible { outline: 2px solid var(--ui-nsc-focus); outline-offset: 2px; }\n\n/* One white card per notice – the edge is a shadow, not a border */\n.ui-nsc__card {\n  position: absolute;\n  top: 0;\n  left: 0;\n  right: 0;\n  overflow: hidden;\n  border-radius: var(--aui-radius-lg, 10px);\n  background: var(--ui-nsc-card);\n  box-shadow: var(--aui-shadow-card, var(--ui-nsc-shadow));\n  transform-origin: 50% 100%;\n  transition:\n    transform 460ms var(--ui-nsc-ease),\n    opacity 300ms ease,\n    box-shadow 300ms ease;\n}\n/* Cards behind show only their edge */\n.ui-nsc__card > * { transition: opacity 260ms ease; }\n.ui-nsc__card:not([data-depth=\"0\"]) > * { opacity: 0; }\n.ui-nsc__card[data-leaving] {\n  opacity: 0;\n  transform: translateX(40px) scale(0.98) !important;\n  transition: transform 320ms var(--ui-nsc-ease), opacity 240ms ease;\n}\n\n/* Quiet hairline art in the top-right corner, with one blue mark */\n.ui-nsc__pattern {\n  position: absolute;\n  top: 0;\n  right: 0;\n  width: 220px;\n  height: 160px;\n  stroke: var(--ui-nsc-art);\n  stroke-width: 0.75;\n  pointer-events: none;\n  -webkit-mask-image: radial-gradient(120% 110% at 100% 0%, #000 20%, transparent 70%);\n  mask-image: radial-gradient(120% 110% at 100% 0%, #000 20%, transparent 70%);\n}\n.ui-nsc__dot { fill: var(--ui-nsc-accent); stroke: none; }\n.ui-nsc__fill { fill: var(--ui-nsc-accent-bg); }\n\n.ui-nsc__body {\n  position: relative;\n  padding: 12px 12px 16px 16px;\n}\n.ui-nsc__top {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 8px;\n  min-height: 28px;\n  margin-bottom: 8px;\n}\n/* Tiny label: tinted fill, 10px uppercase */\n.ui-nsc__badge {\n  padding: 2px 6px;\n  border-radius: var(--aui-radius-sm, 5px);\n  background: var(--ui-nsc-fill);\n  font-size: 10px;\n  font-weight: 500;\n  line-height: 16px;\n  letter-spacing: 0.5px;\n  text-transform: uppercase;\n  color: var(--ui-nsc-text);\n  min-width: 0;\n  overflow: hidden;\n  white-space: nowrap;\n  text-overflow: ellipsis;\n}\n/* Ghost icon button */\n.ui-nsc__close {\n  display: grid;\n  flex: none;\n  place-items: center;\n  width: 28px;\n  height: 28px;\n  border-radius: calc(var(--aui-radius-sm, 5px) + 1px);\n  transition: background-color 140ms ease, color 140ms ease;\n}\n:where(.ui-nsc) .ui-nsc__close { color: var(--ui-nsc-icon); }\n.ui-nsc__close:hover { background: var(--ui-nsc-wash); color: var(--ui-nsc-ink); }\n.ui-nsc__title {\n  margin: 0;\n  padding-right: 4px;\n  font-size: 15px;\n  font-weight: 500;\n  line-height: 24px;\n  color: var(--ui-nsc-strong);\n  text-align: start;\n  overflow-wrap: anywhere;\n}\n.ui-nsc__text {\n  margin: 2px 0 0;\n  padding-right: 4px;\n  max-width: 38ch;\n  font-size: 13px;\n  line-height: 20px;\n  color: var(--ui-nsc-text);\n  text-align: start;\n  overflow-wrap: anywhere;\n  white-space: pre-line;\n}\n/* Secondary button: white with the card's hairline shadow */\n.ui-nsc__btn {\n  display: inline-flex;\n  align-items: center;\n  gap: 6px;\n  /* Grows with a long label instead of leaving the card */\n  min-height: 28px;\n  max-width: 100%;\n  margin-top: 12px;\n  padding: 4px 10px;\n  border-radius: calc(var(--aui-radius, 7px) - 1px);\n  text-align: start;\n  overflow-wrap: anywhere;\n  font-size: 13px;\n  font-weight: 500;\n  line-height: 20px;\n  text-decoration: none;\n  transition: background-color 140ms ease, box-shadow 140ms ease, transform 160ms var(--ui-nsc-ease);\n}\n:where(.ui-nsc) .ui-nsc__btn { background: var(--ui-nsc-card); box-shadow: var(--ui-nsc-shadow); color: var(--ui-nsc-strong); }\n.ui-nsc__btn:has(svg) { padding: 4px 8px 4px 10px; }\n.ui-nsc__btn:hover { box-shadow: var(--ui-nsc-shadow-hover); }\n.ui-nsc__btn:active { transform: scale(0.97); }\n.ui-nsc__btn svg { color: var(--ui-nsc-icon); }\n\n/* Narrow stacks (phones) */\n@container (max-width: 340px) {\n  .ui-nsc__body { padding: 10px 10px 14px 14px; }\n  .ui-nsc__text { max-width: none; }\n}\n/* Touch screens: bigger tap targets */\n@media (pointer: coarse) {\n  .ui-nsc__close { width: 36px; height: 36px; margin: -4px -4px -4px 0; }\n  .ui-nsc__btn { min-height: 36px; padding: 8px 14px; }\n  .ui-nsc__btn:has(svg) { padding: 8px 12px 8px 14px; }\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .ui-nsc, .ui-nsc__card, .ui-nsc__card > * { transition: none; }\n}\n\n/* Dark mode: follows a .dark class or data-theme=\"dark\" on any ancestor, e.g. <html> */\n:where(.dark, [data-theme=\"dark\"]) .ui-nsc {\n  --ui-nsc-card: var(--aui-surface, #1c1c1e);\n  --ui-nsc-strong: var(--aui-ink, #ffffff);\n  --ui-nsc-ink: var(--aui-ink, rgba(255, 255, 255, 0.9));\n  --ui-nsc-text: var(--aui-muted, rgba(255, 255, 255, 0.62));\n  --ui-nsc-icon: var(--aui-muted, rgba(255, 255, 255, 0.46));\n  --ui-nsc-line: var(--aui-border, rgba(255, 255, 255, 0.08));\n  --ui-nsc-wash: var(--aui-subtle, rgba(255, 255, 255, 0.04));\n  --ui-nsc-fill: var(--aui-subtle, rgba(255, 255, 255, 0.08));\n  --ui-nsc-art: rgba(255, 255, 255, 0.07);\n  --ui-nsc-accent: var(--aui-blue, #4fb0fe);\n  --ui-nsc-accent-bg: rgba(77, 141, 255, 0.1);\n  --ui-nsc-focus: var(--aui-focus, rgba(77, 141, 255, 0.8));\n  --ui-nsc-shadow: 0 0 0 0.5px var(--aui-border, rgba(255, 255, 255, 0.1)), 0 1px 2px 0 rgba(0, 0, 0, 0.4);\n  --ui-nsc-shadow-hover: 0 0 0 0.5px var(--aui-border-strong, rgba(255, 255, 255, 0.16)), 0 1px 2px 0 rgba(0, 0, 0, 0.4);\n}\n:where(.dark, [data-theme=\"dark\"]) .ui-nsc .ui-nsc__btn { background: rgba(255, 255, 255, 0.04); }\n`;\n"}]}