{
  "name": "notification-stack",
  "type": "registry:component",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "type": "registry:component",
      "content": "\"use client\";\n\n/**\n * @author: @dorianbaffier\n * @description: Notification Stack\n * @version: 1.1.0\n * @date: 2026-10-14\n * @license: MIT\n * @website: https://kokonutui.com\n * @github: https://github.com/kokonut-labs/kokonutui\n */\n\nimport { CalendarDays, CreditCard, Mail, MessageCircle, X } from \"lucide-react\";\nimport {\n  type KeyboardEvent,\n  type PointerEvent,\n  type ReactNode,\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface StackNotification {\n  id: string;\n  app: string;\n  title: string;\n  body: string;\n  time: string;\n  icon?: ReactNode;\n}\n\nconst DEFAULT_NOTIFICATIONS: StackNotification[] = [\n  {\n    id: \"n-1\",\n    app: \"Messages\",\n    title: \"Maya Chen\",\n    body: \"Pushed the fix. Can you take a look before standup?\",\n    time: \"now\",\n    icon: (\n      <span className=\"flex size-8 items-center justify-center rounded-lg bg-emerald-500 text-white\">\n        <MessageCircle className=\"size-4\" />\n      </span>\n    ),\n  },\n  {\n    id: \"n-2\",\n    app: \"Calendar\",\n    title: \"Design review in 10 min\",\n    body: \"Room 4B · Kokonut UI roadmap\",\n    time: \"9:50\",\n    icon: (\n      <span className=\"flex size-8 items-center justify-center rounded-lg bg-red-500 text-white\">\n        <CalendarDays className=\"size-4\" />\n      </span>\n    ),\n  },\n  {\n    id: \"n-3\",\n    app: \"Mail\",\n    title: \"Invoice #2041 paid\",\n    body: \"Acme Inc. paid $1,280.00. The receipt is attached.\",\n    time: \"9:12\",\n    icon: (\n      <span className=\"flex size-8 items-center justify-center rounded-lg bg-sky-500 text-white\">\n        <Mail className=\"size-4\" />\n      </span>\n    ),\n  },\n  {\n    id: \"n-4\",\n    app: \"Wallet\",\n    title: \"Card payment\",\n    body: \"$4.50 at Blue Bottle Coffee\",\n    time: \"8:31\",\n    icon: (\n      <span className=\"flex size-8 items-center justify-center rounded-lg bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900\">\n        <CreditCard className=\"size-4\" />\n      </span>\n    ),\n  },\n];\n\n// Collapsed: how much of each card behind the front one peeks out.\nconst PEEK = 10;\nconst VISIBLE_BEHIND = 2;\nconst GAP = 8;\n// Used until a card has been measured, so server and client agree.\nconst FALLBACK_HEIGHT = 76;\n\n// A swipe dismisses when it travels this far, or when it is a quick flick\n// (px per ms), whichever comes first. A flick shouldn't need to go far.\nconst DISMISS_DISTANCE = 100;\nconst DISMISS_VELOCITY = 0.11;\n// Movement before we decide whether the gesture is a horizontal swipe or a\n// vertical scroll.\nconst AXIS_LOCK = 6;\n// Snap-back spring (critically damped, so it never wobbles past rest but\n// still carries the release momentum a little further before it turns).\nconst SPRING_STIFFNESS = 520;\nconst SPRING_DAMPING = 2 * Math.sqrt(SPRING_STIFFNESS);\n// Dismiss flight: a released card keeps its own speed (px/s), never slower\n// than this, and picks up a little speed as it goes.\nconst FLIGHT_MIN_SPEED = 1400;\nconst FLIGHT_ACCEL = 4200;\n// Velocity is read from the last stretch of the gesture, not the average.\nconst VELOCITY_WINDOW_MS = 80;\nconst EXIT_MS = 200;\nconst EASE_OUT = \"cubic-bezier(0.23, 1, 0.32, 1)\";\n\ninterface Drag {\n  pointerId: number;\n  startX: number;\n  startY: number;\n  /** Where the card already was when grabbed (mid snap-back). */\n  originX: number;\n  dx: number;\n  axis: \"x\" | \"y\" | null;\n  samples: { t: number; x: number }[];\n}\n\nconst opacityFor = (x: number) => Math.max(1 - Math.abs(x) / 240, 0.3);\n\n/** Release velocity in px/ms from the most recent pointer samples. */\nfunction releaseVelocity(samples: { t: number; x: number }[]) {\n  const last = samples.at(-1);\n  if (!last) {\n    return 0;\n  }\n  let first = last;\n  for (let i = samples.length - 1; i >= 0; i--) {\n    const sample = samples[i];\n    if (last.t - sample.t > VELOCITY_WINDOW_MS) {\n      break;\n    }\n    first = sample;\n  }\n  const dt = last.t - first.t;\n  return dt > 0 ? (last.x - first.x) / dt : 0;\n}\n\n/**\n * Which way a released card leaves, or 0 to spring back. A flick counts\n * only when it moves away from rest; flicking a dragged card back toward\n * the middle returns it.\n */\nfunction dismissDirection(dx: number, velocity: number): 1 | -1 | 0 {\n  const flick =\n    Math.abs(velocity) > DISMISS_VELOCITY &&\n    Math.sign(velocity) === Math.sign(dx);\n  if (Math.abs(dx) < DISMISS_DISTANCE && !flick) {\n    return 0;\n  }\n  return (dx === 0 ? velocity : dx) > 0 ? 1 : -1;\n}\n\nconst prefersReducedMotion = () =>\n  typeof window !== \"undefined\" &&\n  window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\nexport interface NotificationStackProps {\n  /** Initial notifications, newest first. The stack tracks dismissals itself. */\n  notifications?: StackNotification[];\n  /** Called after a notification has been swiped or clicked away. */\n  onDismiss?: (id: string) => void;\n  className?: string;\n}\n\nexport default function NotificationStack({\n  notifications = DEFAULT_NOTIFICATIONS,\n  onDismiss,\n  className,\n}: NotificationStackProps) {\n  const [items, setItems] = useState(notifications);\n  const [heights, setHeights] = useState<Record<string, number>>({});\n  const [hovered, setHovered] = useState(false);\n  const [focused, setFocused] = useState(false);\n  const [pinned, setPinned] = useState(false);\n  const [leaving, setLeaving] = useState<Set<string>>(() => new Set());\n\n  const listRef = useRef<HTMLOListElement>(null);\n  const swipeRefs = useRef(new Map<string, HTMLDivElement>());\n  const drag = useRef<Drag | null>(null);\n  const suppressClick = useRef(false);\n  const timers = useRef<number[]>([]);\n  // Where each card last sat, so a leaving card slides out from there.\n  const lastY = useRef(new Map<string, number>());\n  // Live horizontal offset of each card, and its running animation frame.\n  const swipeX = useRef(new Map<string, number>());\n  const frames = useRef(new Map<string, number>());\n  // Cards already on their way out, so a repeated Delete or a double click\n  // can't dismiss (and report) the same card twice.\n  const dismissed = useRef(new Set<string>());\n  // Ends a gesture whose pointer was released off the card before it was\n  // captured (a vertical drag that left the card).\n  const stopListening = useRef<(() => void) | null>(null);\n\n  useEffect(() => {\n    const pending = timers.current;\n    const running = frames.current;\n    return () => {\n      for (const id of pending) {\n        window.clearTimeout(id);\n      }\n      for (const frame of running.values()) {\n        cancelAnimationFrame(frame);\n      }\n      stopListening.current?.();\n    };\n  }, []);\n\n  // Measure each card once on mount, then only when it actually resizes\n  // (text rewrapping), instead of reading layout on every render. Cards are\n  // only ever removed, so the initial set is all there is to observe.\n  useLayoutEffect(() => {\n    const list = listRef.current;\n    if (!list) {\n      return;\n    }\n    const measure = (entries: { id?: string; height: number }[]) => {\n      setHeights((prev) => {\n        let next = prev;\n        for (const { id, height } of entries) {\n          if (id && height && prev[id] !== height) {\n            next = next === prev ? { ...prev } : next;\n            next[id] = height;\n          }\n        }\n        return next;\n      });\n    };\n    const cards = Array.from(list.children) as HTMLElement[];\n    measure(\n      cards.map((card) => ({ id: card.dataset.id, height: card.offsetHeight }))\n    );\n    const observer = new ResizeObserver((entries) => {\n      measure(\n        entries.map((entry) => ({\n          id: (entry.target as HTMLElement).dataset.id,\n          height: Math.round(entry.borderBoxSize[0]?.blockSize ?? 0),\n        }))\n      );\n    });\n    for (const card of cards) {\n      observer.observe(card);\n    }\n    return () => observer.disconnect();\n  }, []);\n\n  const expanded = (hovered || focused || pinned) && items.length > 1;\n  const heightOf = (id: string) => heights[id] ?? FALLBACK_HEIGHT;\n\n  // Cards that are on their way out no longer take up room, so the rest\n  // close the gap while the leaving card slides away.\n  const laidOut = items.filter((item) => !leaving.has(item.id));\n  const frontHeight = laidOut[0] ? heightOf(laidOut[0].id) : 0;\n  const stackHeight = expanded\n    ? laidOut.reduce((sum, item) => sum + heightOf(item.id), 0) +\n      Math.max(laidOut.length - 1, 0) * GAP\n    : frontHeight + Math.min(laidOut.length - 1, VISIBLE_BEHIND) * PEEK;\n\n  const offsets: number[] = [];\n  let running = 0;\n  for (const item of laidOut) {\n    offsets.push(running);\n    running += heightOf(item.id) + GAP;\n  }\n\n  const stopFrame = (id: string) => {\n    const frame = frames.current.get(id);\n    if (frame !== undefined) {\n      cancelAnimationFrame(frame);\n      frames.current.delete(id);\n    }\n  };\n\n  const paint = (id: string, x: number) => {\n    swipeX.current.set(id, x);\n    const node = swipeRefs.current.get(id);\n    if (node) {\n      node.style.transition = \"none\";\n      node.style.transform = x === 0 ? \"\" : `translateX(${x}px)`;\n      node.style.opacity = x === 0 ? \"\" : `${opacityFor(x)}`;\n    }\n  };\n\n  /** Step a per-card simulation on rAF until it reports it is done. */\n  const run = (\n    id: string,\n    step: (dt: number) => boolean,\n    onDone?: () => void\n  ) => {\n    stopFrame(id);\n    let last = performance.now();\n    const tick = (now: number) => {\n      // Clamp the step so a dropped frame or a background tab can't fling\n      // the simulation.\n      const dt = Math.min((now - last) / 1000, 1 / 30);\n      last = now;\n      if (step(dt)) {\n        frames.current.delete(id);\n        onDone?.();\n        return;\n      }\n      frames.current.set(id, requestAnimationFrame(tick));\n    };\n    frames.current.set(id, requestAnimationFrame(tick));\n  };\n\n  /** Spring back to rest, starting with the speed the finger let go at. */\n  const snapBack = (id: string, velocity: number) => {\n    let x = swipeX.current.get(id) ?? 0;\n    let v = velocity * 1000;\n    if (prefersReducedMotion()) {\n      paint(id, 0);\n      return;\n    }\n    run(id, (dt) => {\n      const force = -SPRING_STIFFNESS * x - SPRING_DAMPING * v;\n      v += force * dt;\n      x += v * dt;\n      if (Math.abs(x) < 0.5 && Math.abs(v) < 10) {\n        paint(id, 0);\n        return true;\n      }\n      paint(id, x);\n      return false;\n    });\n  };\n\n  const finishDismiss = (id: string) => {\n    setItems((prev) => prev.filter((item) => item.id !== id));\n    lastY.current.delete(id);\n    swipeX.current.delete(id);\n    setLeaving((prev) => {\n      const next = new Set(prev);\n      next.delete(id);\n      return next;\n    });\n    onDismiss?.(id);\n  };\n\n  /**\n   * Throw the card off the side. A swipe keeps the speed it was released\n   * at (so a hard flick leaves faster than a slow drag past the threshold);\n   * a button or keyboard dismiss starts from rest at the minimum speed.\n   */\n  const dismiss = (id: string, direction: 1 | -1, velocity = 0) => {\n    if (dismissed.current.has(id)) {\n      return;\n    }\n    dismissed.current.add(id);\n    setLeaving((prev) => new Set(prev).add(id));\n    const node = swipeRefs.current.get(id);\n\n    // Keyboard and button dismissals keep focus in the stack: it moves to\n    // the next card (or the previous one, or the list once it is empty)\n    // instead of dropping to the page when this card unmounts.\n    if (node?.contains(document.activeElement)) {\n      const at = laidOut.findIndex((item) => item.id === id);\n      const next = laidOut[at + 1] ?? laidOut[at - 1];\n      const target = next\n        ? swipeRefs.current.get(next.id)?.querySelector(\"article\")\n        : listRef.current;\n      target?.focus();\n    }\n\n    if (!node || prefersReducedMotion()) {\n      stopFrame(id);\n      if (node) {\n        node.style.transition = `opacity ${EXIT_MS}ms ${EASE_OUT}`;\n        node.style.opacity = \"0\";\n      }\n      const timer = window.setTimeout(() => finishDismiss(id), EXIT_MS);\n      timers.current.push(timer);\n      return;\n    }\n\n    const distance = node.offsetWidth * 1.1;\n    let x = swipeX.current.get(id) ?? 0;\n    let speed = Math.max(Math.abs(velocity) * 1000, FLIGHT_MIN_SPEED);\n    run(\n      id,\n      (dt) => {\n        speed += FLIGHT_ACCEL * dt;\n        x += direction * speed * dt;\n        const done = Math.abs(x) >= distance;\n        paint(id, done ? direction * distance : x);\n        node.style.opacity = `${Math.max(1 - Math.abs(x) / distance, 0)}`;\n        return done;\n      },\n      () => finishDismiss(id)\n    );\n  };\n\n  const handlePointerDown = (\n    event: PointerEvent<HTMLDivElement>,\n    index: number,\n    id: string\n  ) => {\n    // Only the front card can be swiped while the stack is collapsed, and a\n    // second finger never takes over a drag that is already running.\n    if (drag.current || leaving.has(id) || (!expanded && index > 0)) {\n      return;\n    }\n    suppressClick.current = false;\n    if (event.pointerType === \"mouse\" && event.button !== 0) {\n      return;\n    }\n    if ((event.target as HTMLElement).closest(\"button\")) {\n      return;\n    }\n    // Catch a card that is still springing back from the last swipe.\n    stopFrame(id);\n    const { pointerId } = event;\n    const release = (up: globalThis.PointerEvent) => {\n      if (up.pointerId !== pointerId) {\n        return;\n      }\n      stopListening.current?.();\n      // The card's own handlers run first; this only clears a gesture that\n      // ended off the card, which would otherwise block the next one.\n      if (drag.current?.pointerId === pointerId) {\n        drag.current = null;\n        if (swipeX.current.get(id)) {\n          snapBack(id, 0);\n        }\n      }\n    };\n    stopListening.current?.();\n    window.addEventListener(\"pointerup\", release);\n    window.addEventListener(\"pointercancel\", release);\n    stopListening.current = () => {\n      window.removeEventListener(\"pointerup\", release);\n      window.removeEventListener(\"pointercancel\", release);\n      stopListening.current = null;\n    };\n    drag.current = {\n      pointerId: event.pointerId,\n      startX: event.clientX,\n      startY: event.clientY,\n      originX: swipeX.current.get(id) ?? 0,\n      dx: 0,\n      axis: null,\n      samples: [],\n    };\n  };\n\n  const handlePointerMove = (\n    event: PointerEvent<HTMLDivElement>,\n    id: string\n  ) => {\n    const current = drag.current;\n    if (!current || current.pointerId !== event.pointerId) {\n      return;\n    }\n    const dx = event.clientX - current.startX;\n    const dy = event.clientY - current.startY;\n\n    if (current.axis === null) {\n      if (Math.abs(dx) < AXIS_LOCK && Math.abs(dy) < AXIS_LOCK) {\n        return;\n      }\n      current.axis = Math.abs(dx) > Math.abs(dy) ? \"x\" : \"y\";\n      if (current.axis === \"x\") {\n        // Keep the swipe alive when the pointer leaves the card.\n        event.currentTarget.setPointerCapture(event.pointerId);\n      }\n    }\n    if (current.axis !== \"x\") {\n      return;\n    }\n\n    current.dx = current.originX + dx;\n    current.samples.push({ t: event.timeStamp, x: current.dx });\n    if (current.samples.length > 12) {\n      current.samples.shift();\n    }\n    // Written straight to the element: no re-render per pointer move.\n    paint(id, current.dx);\n  };\n\n  const handlePointerUp = (event: PointerEvent<HTMLDivElement>, id: string) => {\n    const current = drag.current;\n    if (!current || current.pointerId !== event.pointerId) {\n      return;\n    }\n    drag.current = null;\n    if (current.axis !== \"x\") {\n      // A tap or scroll that caught a card mid spring lets it finish.\n      if (swipeX.current.get(id)) {\n        snapBack(id, 0);\n      }\n      return;\n    }\n    suppressClick.current = true;\n\n    const velocity = releaseVelocity(current.samples);\n    const direction = dismissDirection(current.dx, velocity);\n    if (direction === 0) {\n      snapBack(id, velocity);\n      return;\n    }\n    // Never carry speed that points back toward the stack into the flight.\n    dismiss(id, direction, Math.sign(velocity) === direction ? velocity : 0);\n  };\n\n  const handleCardKeyDown = (event: KeyboardEvent<HTMLElement>, id: string) => {\n    if (event.key === \"Delete\" || event.key === \"Backspace\") {\n      event.preventDefault();\n      // A held key would clear the next card too, now that focus moves to it.\n      if (event.repeat) {\n        return;\n      }\n      dismiss(id, 1);\n    }\n  };\n\n  return (\n    // biome-ignore lint/a11y: hover and focus only widen the stack; every action has its own button.\n    <div\n      className={cn(\"w-full max-w-sm\", className)}\n      onBlur={(event) => {\n        if (!event.currentTarget.contains(event.relatedTarget as Node)) {\n          setFocused(false);\n        }\n      }}\n      onFocus={() => setFocused(true)}\n      onPointerEnter={(event) => {\n        // Touch screens fire pointerenter on tap; only a real hover expands.\n        if (event.pointerType === \"mouse\") {\n          setHovered(true);\n        }\n      }}\n      onPointerLeave={(event) => {\n        if (event.pointerType === \"mouse\") {\n          setHovered(false);\n        }\n      }}\n    >\n      <div className=\"mb-3 flex h-7 items-center justify-between px-1\">\n        <p className=\"font-medium text-sm text-zinc-900 dark:text-zinc-100\">\n          Notifications\n          <span className=\"ml-2 text-zinc-500 tabular-nums dark:text-zinc-400\">\n            {laidOut.length}\n          </span>\n        </p>\n        {laidOut.length > 1 && (\n          <button\n            aria-expanded={expanded}\n            className=\"rounded-md px-2 py-1 text-sm text-zinc-500 transition-colors duration-150 hover:bg-zinc-100 hover:text-zinc-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100\"\n            onClick={() => setPinned((prev) => !prev)}\n            type=\"button\"\n          >\n            {expanded ? \"Show less\" : \"Show all\"}\n          </button>\n        )}\n      </div>\n\n      <ol\n        aria-label=\"Notifications\"\n        className=\"relative outline-none transition-[height] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none\"\n        ref={listRef}\n        style={{ height: stackHeight }}\n        // Focus lands here when the last card is dismissed from the keyboard.\n        tabIndex={-1}\n      >\n        {items.map((item) => {\n          const index = laidOut.findIndex((card) => card.id === item.id);\n          const isLeaving = index === -1;\n          const depth = Math.max(index, 0);\n          const hiddenBehind = !(expanded || depth <= VISIBLE_BEHIND);\n          const scale = expanded || isLeaving ? 1 : 1 - depth * 0.05;\n          // Bottom-align the cards behind so a strip of each peeks out below\n          // the front card, whatever their own heights.\n          const restingY = expanded\n            ? offsets[depth]\n            : frontHeight + depth * PEEK - heightOf(item.id) * scale;\n          const y = isLeaving ? (lastY.current.get(item.id) ?? 0) : restingY;\n          if (!isLeaving) {\n            lastY.current.set(item.id, restingY);\n          }\n\n          return (\n            <li\n              className=\"absolute inset-x-0 top-0 origin-top transition-[transform,opacity] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-opacity\"\n              data-id={item.id}\n              key={item.id}\n              style={{\n                transform: `translateY(${y}px) scale(${scale})`,\n                opacity: hiddenBehind ? 0 : 1,\n                zIndex: items.length - depth,\n                pointerEvents: hiddenBehind ? \"none\" : undefined,\n              }}\n            >\n              <div\n                className=\"touch-pan-y\"\n                onLostPointerCapture={(event) => {\n                  // Touch starts with an implicit capture on the element\n                  // under the finger; moving it to this card fires a\n                  // bubbling lostpointercapture that is not the end of the\n                  // swipe.\n                  if (event.target === event.currentTarget) {\n                    handlePointerUp(event, item.id);\n                  }\n                }}\n                onPointerCancel={(event) => handlePointerUp(event, item.id)}\n                onPointerDown={(event) =>\n                  handlePointerDown(event, depth, item.id)\n                }\n                onPointerMove={(event) => handlePointerMove(event, item.id)}\n                onPointerUp={(event) => handlePointerUp(event, item.id)}\n                ref={(node) => {\n                  if (node) {\n                    swipeRefs.current.set(item.id, node);\n                  } else {\n                    swipeRefs.current.delete(item.id);\n                  }\n                }}\n              >\n                {/* biome-ignore lint/a11y: the card is focusable so Delete can dismiss it, and tapping it fans out the stack; both also have buttons. */}\n                <article\n                  aria-label={`${item.app}: ${item.title}. ${item.body}`}\n                  className={cn(\n                    \"group/card relative flex select-none gap-3 rounded-2xl border border-zinc-200 bg-white p-3.5 shadow-sm outline-none dark:border-zinc-800 dark:bg-zinc-900\",\n                    \"focus-visible:ring-2 focus-visible:ring-zinc-400\",\n                    !expanded &&\n                      depth === 0 &&\n                      laidOut.length > 1 &&\n                      \"cursor-pointer\"\n                  )}\n                  onClick={() => {\n                    if (suppressClick.current) {\n                      suppressClick.current = false;\n                      return;\n                    }\n                    if (!expanded) {\n                      setPinned(true);\n                    }\n                  }}\n                  onKeyDown={(event) => handleCardKeyDown(event, item.id)}\n                  tabIndex={hiddenBehind ? -1 : 0}\n                >\n                  <NotificationContent\n                    item={item}\n                    muted={!expanded && depth > 0}\n                    onDismiss={() => dismiss(item.id, 1)}\n                    tabbable={!hiddenBehind}\n                  />\n                </article>\n              </div>\n            </li>\n          );\n        })}\n      </ol>\n\n      {laidOut.length === 0 && (\n        <p className=\"py-6 text-center text-sm text-zinc-500 dark:text-zinc-400\">\n          You're all caught up.\n        </p>\n      )}\n    </div>\n  );\n}\n\nfunction NotificationContent({\n  item,\n  muted,\n  tabbable,\n  onDismiss,\n}: {\n  item: StackNotification;\n  /** Cards behind the front one show only their edge, so their content\n   * fades out instead of poking through. */\n  muted: boolean;\n  tabbable: boolean;\n  onDismiss: () => void;\n}) {\n  return (\n    <>\n      <div\n        className={cn(\n          \"flex min-w-0 flex-1 gap-3 transition-opacity duration-200\",\n          muted && \"opacity-0\"\n        )}\n      >\n        <div className=\"shrink-0\">{item.icon}</div>\n        <div className=\"min-w-0 flex-1\">\n          <div className=\"flex items-baseline justify-between gap-2\">\n            <p className=\"truncate font-medium text-sm text-zinc-900 dark:text-zinc-100\">\n              {item.title}\n            </p>\n            <span className=\"shrink-0 text-xs text-zinc-400 tabular-nums\">\n              {item.time}\n            </span>\n          </div>\n          <p className=\"line-clamp-2 text-sm text-zinc-500 dark:text-zinc-400\">\n            {item.body}\n          </p>\n        </div>\n      </div>\n      <button\n        aria-label={`Dismiss ${item.title}`}\n        className={cn(\n          \"absolute -top-2 -left-2 flex size-6 items-center justify-center rounded-full border border-zinc-200 bg-white text-zinc-500 opacity-0 transition-opacity duration-150 hover:text-zinc-900 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 group-hover/card:opacity-100 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-100\",\n          muted && \"hidden\"\n        )}\n        onClick={(event) => {\n          event.stopPropagation();\n          onDismiss();\n        }}\n        tabIndex={tabbable ? 0 : -1}\n        type=\"button\"\n      >\n        <X className=\"size-3\" />\n      </button>\n    </>\n  );\n}\n",
      "path": "/components/kokonutui/notification-stack.tsx",
      "target": "components/kokonutui/notification-stack.tsx"
    }
  ]
}