{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"notification-inbox","type":"registry:component","title":"Notification inbox","description":"The bell, done properly: its own circle grows into the inbox, new notifications swing it and roll the count, and read ones fold out of Unread.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/text-morph.json?as=dependency","https://hairlineui.com/r/number-roll.json?as=dependency","https://hairlineui.com/r/status-button.json?as=dependency"],"files":[{"path":"registry/notification-inbox.tsx","type":"registry:component","content":"// Hairline UI · notification-inbox · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { Archive, Bell, X } from \"lucide-react\";\nimport { NumberRoll } from \"./number-roll\";\nimport { RelativeTime, toTime, type DateLike } from \"./relative-time\";\nimport { StatusButton, type ActionStatus } from \"./status-button\";\nimport { TextMorph } from \"./text-morph\";\nimport { useRowMotion } from \"./use-row-motion\";\n\n/* ─────────────────────────────────────────────────────────\n * NOTIFICATION INBOX: the bell, done properly\n *\n *   arrive    a new notification rings the bell (a short swing\n *             from its top) and the count rolls; if the inbox is\n *             open it fades in on top while the rest glide down\n *   open      the bell's own circle grows into the inbox: size\n *             and corners ease, the bell fades into the list,\n *             and it folds back the same way\n *   tabs      Unread / All, the highlight sliding between them;\n *             Unread counts as you go\n *   read      opening a notification reads it; the dot shrinks,\n *             the title settles to grey, and in Unread the row\n *             folds away a moment later. Mark all read does\n *             them together\n *   archive   the row folds away and the rest glide up\n *   empty     \"You're all caught up\", with a drawn check\n *\n * Items are plain records; subscribe() feeds new ones from\n * Knock, Novu, Supabase Realtime or your own stream.\n * ───────────────────────────────────────────────────────── */\n\nexport type { DateLike };\n\nexport interface InboxItem {\n  id: string;\n  title: string;\n  body?: string;\n  at: DateLike;\n  read?: boolean;\n  actor?: { name: string; image?: string };\n  /** Where opening it goes. */\n  href?: string;\n}\n\nexport interface NotificationInboxProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"defaultValue\" | \"onChange\"> {\n  value?: InboxItem[];\n  defaultValue?: InboxItem[];\n  onValueChange?: (items: InboxItem[]) => void;\n  /** Feeds new notifications: call push with one or many; return a function that stops it. */\n  subscribe?: (push: (items: InboxItem | InboxItem[]) => void) => () => void;\n  /** Saves read state. Throw to put it back. */\n  onRead?: (ids: string[], read: boolean) => Promise<unknown> | unknown;\n  onArchive?: (ids: string[]) => Promise<unknown> | unknown;\n  /** Called when a notification is opened (after it's marked read). */\n  onOpen?: (item: InboxItem) => void;\n  /** Older notifications, newest first; an empty list means there are no more. */\n  loadMore?: (before: InboxItem) => Promise<InboxItem[]>;\n  /** Which edge of the bell the inbox lines up with. */\n  align?: \"end\" | \"start\";\n  label?: string;\n  defaultOpen?: boolean;\n}\n\ntype Phase = \"closed\" | \"opening\" | \"open\" | \"closing\";\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst DURATION = 460;\nconst WIDTH = 384;\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\nconst NONE: InboxItem[] = [];\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\nconst DAY = 86_400_000;\nconst groupOf = (at: DateLike) => {\n  const t = new Date(toTime(at));\n  const today = new Date();\n  today.setHours(0, 0, 0, 0);\n  const start = today.getTime();\n  return t.getTime() >= start ? \"Today\" : t.getTime() >= start - DAY ? \"Yesterday\" : \"Earlier\";\n};\n\nfunction Avatar({ item }: { item: InboxItem }) {\n  if (item.actor?.image)\n    // eslint-disable-next-line @next/next/no-img-element\n    return <img src={item.actor.image} alt=\"\" className=\"size-7 shrink-0 rounded-full object-cover shadow-[0_0_0_1px_var(--border)]\" />;\n  const letters = (item.actor?.name ?? \"•\")\n    .split(/\\s+/)\n    .slice(0, 2)\n    .map((w) => w[0]?.toUpperCase())\n    .join(\"\");\n  return (\n    <span aria-hidden=\"true\" className=\"grid size-7 shrink-0 place-items-center rounded-full bg-muted text-[10.5px] font-medium text-muted-foreground shadow-[inset_0_0_0_1px_var(--border)]\">\n      {letters}\n    </span>\n  );\n}\n\ninterface RowProps {\n  item: InboxItem;\n  reduced: boolean;\n  canArchive: boolean;\n  act: React.RefObject<{ open: (i: InboxItem) => void; read: (i: InboxItem, read: boolean) => void; archive: (i: InboxItem) => void }>;\n}\n\nconst Row = React.memo(function Row({ item, reduced, canArchive, act }: RowProps) {\n  const unread = !item.read;\n  const Body = item.href ? \"a\" : \"button\";\n  return (\n    <li data-row-id={item.id} className=\"group relative\">\n      <Body\n        {...(item.href ? { href: item.href } : { type: \"button\" as const })}\n        onClick={() => act.current.open(item)}\n        className={`flex w-full cursor-pointer items-start gap-3 rounded-[12px] px-3 py-2.5 pr-16 text-left transition-colors hover:bg-accent/60 ${FOCUS}`}\n      >\n        <span className=\"relative mt-0.5\">\n          <Avatar item={item} />\n        </span>\n        <span className=\"min-w-0 flex-1\">\n          <span className={`block text-[13px] leading-[1.4] transition-colors duration-500 ${unread ? \"font-medium text-foreground\" : \"text-muted-foreground\"}`}>\n            {item.title}\n            <span className=\"sr-only\">{unread ? \", unread\" : \"\"}</span>\n          </span>\n          {item.body && <span className=\"mt-0.5 line-clamp-2 block text-[12px] leading-[1.45] text-muted-foreground\">{item.body}</span>}\n          <span className=\"mt-1 block text-[11px] text-muted-foreground\">\n            <RelativeTime date={item.at} />\n          </span>\n        </span>\n      </Body>\n      {/* The unread dot doubles as the read / unread toggle; archive sits beside it. */}\n      <span className=\"absolute top-2.5 right-2 flex items-center gap-0.5\">\n        {canArchive && (\n          <button\n            type=\"button\"\n            aria-label={`Archive “${item.title}”`}\n            onClick={() => act.current.archive(item)}\n            className={`grid size-7 cursor-pointer place-items-center rounded-full text-muted-foreground opacity-0 transition-[opacity,background-color,color] group-focus-within:opacity-100 group-hover:opacity-100 hover:bg-accent hover:text-foreground [@media(pointer:coarse)]:opacity-100 ${FOCUS}`}\n          >\n            <Archive className=\"size-3.5\" strokeWidth={1.75} aria-hidden=\"true\" />\n          </button>\n        )}\n        <button\n          type=\"button\"\n          aria-label={unread ? `Mark “${item.title}” read` : `Mark “${item.title}” unread`}\n          aria-pressed={!unread}\n          onClick={() => act.current.read(item, unread)}\n          className={`grid size-7 cursor-pointer place-items-center rounded-full transition-colors hover:bg-accent ${FOCUS}`}\n        >\n          <span\n            aria-hidden=\"true\"\n            className={`size-2 rounded-full transition-[transform,background-color,box-shadow] ${unread ? \"bg-primary\" : \"bg-transparent shadow-[inset_0_0_0_1px_var(--border)] group-hover:shadow-[inset_0_0_0_1px_var(--muted-foreground)]\"}`}\n            style={{ transform: unread ? \"scale(1)\" : \"scale(0.8)\", transitionDuration: reduced ? \"0ms\" : \"420ms\", transitionTimingFunction: EASE }}\n          />\n        </button>\n      </span>\n    </li>\n  );\n});\n\nexport function NotificationInbox({\n  value,\n  defaultValue = NONE,\n  onValueChange,\n  subscribe,\n  onRead,\n  onArchive,\n  onOpen,\n  loadMore,\n  align = \"end\",\n  label = \"Inbox\",\n  defaultOpen = false,\n  className = \"\",\n  ...props\n}: NotificationInboxProps) {\n  const reduced = useReducedMotion();\n  const id = React.useId();\n  const [inner, setInner] = React.useState(defaultValue);\n  const items = value ?? inner;\n  const [phase, setPhase] = React.useState<Phase>(defaultOpen ? \"open\" : \"closed\");\n  const [tab, setTab] = React.useState<\"unread\" | \"all\">(\"unread\");\n  const [leaving, setLeaving] = React.useState<string[]>([]);\n  const [markAll, setMarkAll] = React.useState<ActionStatus>(\"idle\");\n  const [loading, setLoading] = React.useState(false);\n  const [noMore, setNoMore] = React.useState(!loadMore);\n  const [panelHeight, setPanelHeight] = React.useState(0);\n  const [fit, setFit] = React.useState({ width: WIDTH, shift: 0 });\n  const [announce, setAnnounce] = React.useState(\"\");\n  const [bar, setBar] = React.useState({ left: 0, width: 0, ready: false });\n  const wrapRef = React.useRef<HTMLDivElement>(null);\n  const bellRef = React.useRef<HTMLButtonElement>(null);\n  const iconRef = React.useRef<HTMLSpanElement>(null);\n  const panelRef = React.useRef<HTMLDivElement>(null);\n  const listRef = React.useRef<HTMLUListElement>(null);\n  const ghostRef = React.useRef<HTMLDivElement>(null);\n  const tabRefs = React.useRef<Record<string, HTMLButtonElement | null>>({});\n  const timer = React.useRef(0);\n  const latest = React.useRef(items);\n  latest.current = items;\n  const expanded = phase === \"open\";\n  const mounted = phase !== \"closed\";\n\n  const commit = React.useCallback(\n    (next: InboxItem[]) => {\n      if (value === undefined) setInner(next);\n      onValueChange?.(next);\n    },\n    [value, onValueChange],\n  );\n\n  const unreadCount = items.filter((i) => !i.read).length;\n  const shown = items.filter((i) => tab === \"all\" || !i.read || leaving.includes(i.id));\n  const rows: ({ kind: \"head\"; key: string } | { kind: \"item\"; item: InboxItem })[] = [];\n  let lastGroup = \"\";\n  for (const item of shown) {\n    const g = groupOf(item.at);\n    if (g !== lastGroup) rows.push({ kind: \"head\", key: `head-${g}` });\n    lastGroup = g;\n    rows.push({ kind: \"item\", item });\n  }\n  const ranks = React.useMemo(() => new Map(rows.map((r, i) => [r.kind === \"head\" ? r.key : r.item.id, i])), [rows.map((r) => (r.kind === \"head\" ? r.key : r.item.id)).join(\"|\")]); // eslint-disable-line react-hooks/exhaustive-deps\n  useRowMotion(listRef, { reduced, ranks, ghosts: ghostRef, resetKey: tab });\n\n  // New notifications: the bell swings, the count rolls.\n  const ring = React.useCallback(() => {\n    if (reduced) return;\n    iconRef.current?.animate(\n      [{ transform: \"rotate(0deg)\" }, { transform: \"rotate(-16deg)\" }, { transform: \"rotate(13deg)\" }, { transform: \"rotate(-8deg)\" }, { transform: \"rotate(4deg)\" }, { transform: \"rotate(0deg)\" }],\n      { duration: 720, easing: \"ease-out\" },\n    );\n  }, [reduced]);\n\n  React.useEffect(() => {\n    if (!subscribe) return;\n    return subscribe((incoming) => {\n      const list = Array.isArray(incoming) ? incoming : [incoming];\n      const fresh = list.filter((n) => !latest.current.some((i) => i.id === n.id));\n      if (!fresh.length) return;\n      commit([...fresh, ...latest.current]);\n      ring();\n      setAnnounce(fresh.length === 1 ? `New: ${fresh[0].title}` : `${fresh.length} new notifications`);\n    });\n  }, [subscribe, commit, ring]);\n\n  const measureFit = () => {\n    const wrap = wrapRef.current;\n    if (!wrap) return;\n    const r = wrap.getBoundingClientRect();\n    const vw = document.documentElement.clientWidth;\n    const width = Math.min(WIDTH, vw - 24);\n    // Lined up with the bell's edge, nudged back on screen.\n    const shift = align === \"end\" ? Math.max(0, 12 - (r.right - width)) : Math.min(0, vw - 12 - (r.left + width));\n    setFit((f) => (f.width === width && f.shift === shift ? f : { width, shift }));\n  };\n\n  // Opened from the start: fit it to the screen before the first paint.\n  React.useLayoutEffect(() => {\n    if (defaultOpen) measureFit();\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  const open = () => {\n    if (phase === \"open\" || phase === \"opening\") return;\n    window.clearTimeout(timer.current);\n    measureFit();\n    setPhase(reduced ? \"open\" : \"opening\");\n  };\n  const close = React.useCallback(\n    (refocus = true) => {\n      window.clearTimeout(timer.current);\n      if (refocus) bellRef.current?.focus({ preventScroll: true });\n      if (reduced) return setPhase(\"closed\");\n      setPhase(\"closing\");\n      timer.current = window.setTimeout(() => setPhase(\"closed\"), DURATION);\n    },\n    [reduced],\n  );\n\n  React.useEffect(() => {\n    if (phase !== \"opening\") return;\n    let inner = 0;\n    const outer = requestAnimationFrame(() => (inner = requestAnimationFrame(() => setPhase(\"open\"))));\n    return () => {\n      cancelAnimationFrame(outer);\n      cancelAnimationFrame(inner);\n    };\n  }, [phase]);\n  React.useEffect(() => () => window.clearTimeout(timer.current), []);\n\n  React.useLayoutEffect(() => {\n    const panel = panelRef.current;\n    if (!panel) return;\n    const measure = () => setPanelHeight(panel.offsetHeight);\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(panel);\n    return () => observer.disconnect();\n  }, [mounted]);\n\n  React.useLayoutEffect(() => {\n    const b = tabRefs.current[tab];\n    if (!b) return;\n    setBar((p) => (p.left === b.offsetLeft && p.width === b.offsetWidth ? p : { left: b.offsetLeft, width: b.offsetWidth, ready: p.width > 0 }));\n  }, [tab, mounted, unreadCount]);\n\n  React.useEffect(() => {\n    if (expanded) tabRefs.current[tab]?.focus({ preventScroll: true });\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [expanded]);\n\n  React.useEffect(() => {\n    if (!expanded) return;\n    const onDown = (e: PointerEvent) => {\n      if (!wrapRef.current?.contains(e.target as Node)) close(false);\n    };\n    document.addEventListener(\"pointerdown\", onDown);\n    return () => document.removeEventListener(\"pointerdown\", onDown);\n  }, [expanded, close]);\n\n  const setRead = async (ids: string[], read: boolean) => {\n    const before = latest.current;\n    commit(before.map((i) => (ids.includes(i.id) ? { ...i, read } : i)));\n    if (read && tab === \"unread\") {\n      // Read rows stay a moment in Unread, then fold away.\n      setLeaving((l) => [...l, ...ids]);\n      window.setTimeout(() => setLeaving((l) => l.filter((x) => !ids.includes(x))), 900);\n    }\n    try {\n      await onRead?.(ids, read);\n    } catch {\n      commit(latest.current.map((i) => (ids.includes(i.id) ? { ...i, read: !read } : i)));\n      setAnnounce(\"That change couldn't be saved.\");\n    }\n  };\n\n  const act = React.useRef<RowProps[\"act\"][\"current\"]>(null!);\n  act.current = {\n    open: (item) => {\n      if (!item.read) setRead([item.id], true);\n      onOpen?.(item);\n    },\n    read: (item, read) => {\n      setRead([item.id], read);\n      setAnnounce(read ? \"Marked read.\" : \"Marked unread.\");\n    },\n    archive: async (item) => {\n      const before = latest.current;\n      commit(before.filter((i) => i.id !== item.id));\n      setAnnounce(\"Archived.\");\n      try {\n        await onArchive?.([item.id]);\n      } catch {\n        commit([item, ...latest.current].sort((a, b) => toTime(b.at) - toTime(a.at)));\n        setAnnounce(\"That couldn't be archived.\");\n      }\n    },\n  };\n\n  const readAll = async () => {\n    const ids = latest.current.filter((i) => !i.read).map((i) => i.id);\n    if (!ids.length) return;\n    setMarkAll(\"pending\");\n    try {\n      await setRead(ids, true);\n      setMarkAll(\"success\");\n      setAnnounce(\"All marked read.\");\n    } catch {\n      setMarkAll(\"error\");\n    }\n  };\n\n  const more = async () => {\n    const last = latest.current[latest.current.length - 1];\n    if (!loadMore || !last || loading) return;\n    setLoading(true);\n    try {\n      const older = await loadMore(last);\n      if (!older.length) setNoMore(true);\n      else commit([...latest.current, ...older.filter((o) => !latest.current.some((i) => i.id === o.id))]);\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  const width = expanded ? fit.width : 36;\n  const height = expanded ? panelHeight : 36;\n  const empty = !shown.length;\n\n  return (\n    <div\n      ref={wrapRef}\n      className={`relative inline-flex ${className}`}\n      onKeyDown={(e) => {\n        if (e.key === \"Escape\" && expanded) {\n          e.preventDefault();\n          close();\n        }\n      }}\n      {...props}\n    >\n      <button\n        ref={bellRef}\n        type=\"button\"\n        aria-label={`${label}${unreadCount ? `, ${unreadCount} unread` : \"\"}`}\n        aria-haspopup=\"dialog\"\n        aria-expanded={expanded}\n        aria-controls={mounted ? `${id}-panel` : undefined}\n        onClick={() => (expanded ? close() : open())}\n        className={`relative grid size-9 cursor-pointer place-items-center rounded-full text-foreground shadow-[inset_0_0_0_1px_var(--border)] transition-colors hover:bg-accent ${FOCUS}`}\n      >\n        <span ref={iconRef} className=\"grid origin-top place-items-center\">\n          <Bell className=\"size-4\" strokeWidth={1.75} aria-hidden=\"true\" />\n        </span>\n        <span\n          aria-hidden=\"true\"\n          className=\"absolute -top-1 -right-1 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium text-primary-foreground tabular-nums shadow-[0_0_0_2px_var(--background)]\"\n          // Hidden while the inbox is open: the count is in the Unread tab then.\n          style={{ transform: unreadCount && !mounted ? \"scale(1)\" : \"scale(0)\", transition: reduced ? \"none\" : `transform 380ms ${unreadCount && !mounted ? \"cubic-bezier(0.34,1.36,0.64,1)\" : EASE}` }}\n        >\n          <NumberRoll value={Math.min(99, unreadCount || 1)} />\n        </span>\n      </button>\n\n      {mounted && (\n        <div\n          id={`${id}-panel`}\n          role=\"dialog\"\n          aria-label={label}\n          className=\"absolute top-0 z-40 overflow-hidden bg-popover text-popover-foreground\"\n          style={{\n            [align === \"end\" ? \"right\" : \"left\"]: expanded ? (align === \"end\" ? -fit.shift : fit.shift) : 0,\n            width,\n            height,\n            borderRadius: expanded ? 20 : 18,\n            boxShadow: \"inset 0 0 0 1px var(--border)\",\n            transition: reduced ? \"none\" : `width ${DURATION}ms ${EASE}, height ${DURATION}ms ${EASE}, right ${DURATION}ms ${EASE}, left ${DURATION}ms ${EASE}, border-radius ${DURATION}ms ${EASE}`,\n          }}\n        >\n          {/* The bell, fading as its circle grows into the inbox. */}\n          <span\n            aria-hidden=\"true\"\n            className={`pointer-events-none absolute top-0 grid size-9 place-items-center ${align === \"end\" ? \"right-0\" : \"left-0\"}`}\n            style={{ opacity: expanded ? 0 : 1, transition: reduced ? \"none\" : `opacity ${expanded ? 140 : 260}ms ${EASE} ${expanded ? 0 : 140}ms` }}\n          >\n            <Bell className=\"size-4\" strokeWidth={1.75} />\n          </span>\n\n          <div\n            ref={panelRef}\n            inert={!expanded}\n            className={`absolute top-0 ${align === \"end\" ? \"right-0\" : \"left-0\"}`}\n            style={{ width: fit.width, opacity: expanded ? 1 : 0, transition: reduced ? \"none\" : `opacity ${expanded ? 280 : 120}ms ${EASE} ${expanded ? 100 : 0}ms` }}\n          >\n            <div className=\"flex items-center gap-2 px-3 pt-3 pb-2\">\n              <div role=\"tablist\" aria-label={label} className=\"relative flex items-center gap-0.5\">\n                <span\n                  aria-hidden=\"true\"\n                  className=\"absolute top-0 left-0 h-7 rounded-full bg-accent shadow-[inset_0_0_0_1px_var(--border)]\"\n                  style={{ width: bar.width, transform: `translateX(${bar.left}px)`, transition: bar.ready && !reduced ? `transform 360ms ${EASE}, width 360ms ${EASE}` : \"none\" }}\n                />\n                {([\"unread\", \"all\"] as const).map((t) => (\n                  <button\n                    key={t}\n                    ref={(el) => {\n                      tabRefs.current[t] = el;\n                    }}\n                    role=\"tab\"\n                    aria-selected={tab === t}\n                    tabIndex={tab === t ? 0 : -1}\n                    onClick={() => setTab(t)}\n                    onKeyDown={(e) => {\n                      if (e.key !== \"ArrowLeft\" && e.key !== \"ArrowRight\") return;\n                      e.preventDefault();\n                      const next = t === \"unread\" ? \"all\" : \"unread\";\n                      setTab(next);\n                      tabRefs.current[next]?.focus();\n                    }}\n                    className={`relative inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-full px-3 text-[12.5px] font-medium transition-colors ${FOCUS} ${tab === t ? \"text-foreground\" : \"text-muted-foreground hover:text-foreground\"}`}\n                  >\n                    {t === \"unread\" ? \"Unread\" : \"All\"}\n                    {t === \"unread\" && (\n                      <span className=\"text-[11.5px] font-normal text-muted-foreground tabular-nums\">\n                        <NumberRoll value={unreadCount} />\n                      </span>\n                    )}\n                  </button>\n                ))}\n              </div>\n              <span className=\"ml-auto\" />\n              {onRead && (\n                <StatusButton\n                  size=\"sm\"\n                  variant=\"ghost\"\n                  status={markAll}\n                  labels={{ idle: \"Mark all read\", pending: \"Marking\", success: \"All read\", error: \"Try again\" }}\n                  onClick={readAll}\n                  onReset={() => setMarkAll(\"idle\")}\n                  aria-disabled={(!unreadCount && markAll === \"idle\") || undefined}\n                  className={!unreadCount && markAll === \"idle\" ? \"pointer-events-none opacity-40\" : \"\"}\n                />\n              )}\n              <button\n                type=\"button\"\n                aria-label={`Close ${label.toLowerCase()}`}\n                onClick={() => close()}\n                className={`grid size-7 shrink-0 cursor-pointer place-items-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground ${FOCUS}`}\n              >\n                <X className=\"size-3.5\" strokeWidth={1.75} aria-hidden=\"true\" />\n              </button>\n            </div>\n\n            <div className=\"max-h-[min(26rem,calc(100dvh_-_10rem))] overflow-y-auto overscroll-contain px-1.5 pb-1.5\" role=\"tabpanel\">\n              <div className=\"relative\">\n                <ul ref={listRef} aria-label={tab === \"unread\" ? \"Unread notifications\" : \"All notifications\"} className=\"relative\">\n                  {rows.map((r) =>\n                    r.kind === \"head\" ? (\n                      <li key={r.key} data-row-id={r.key} aria-hidden=\"true\" className=\"px-3 pt-2 pb-1 text-[11px] font-medium text-muted-foreground\">\n                        {r.key.slice(5)}\n                      </li>\n                    ) : (\n                      <Row key={r.item.id} item={r.item} reduced={reduced} canArchive={Boolean(onArchive)} act={act} />\n                    ),\n                  )}\n                </ul>\n                <div ref={ghostRef} aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0\" />\n              </div>\n\n              <div\n                className=\"grid\"\n                style={{ gridTemplateRows: empty ? \"1fr\" : \"0fr\", opacity: empty ? 1 : 0, transition: reduced ? \"none\" : `grid-template-rows 420ms ${EASE}, opacity 300ms ${EASE} ${empty ? 120 : 0}ms` }}\n              >\n                <div className=\"min-h-0 min-w-0 overflow-clip\">\n                  <div className=\"flex flex-col items-center gap-2 py-9 text-center\">\n                    <span className=\"grid size-9 place-items-center rounded-full text-emerald-600 shadow-[inset_0_0_0_1px_var(--border)] dark:text-emerald-400\">\n                      <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className=\"size-4\">\n                        <path\n                          d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n                          stroke=\"currentColor\"\n                          strokeWidth=\"1.8\"\n                          strokeLinecap=\"round\"\n                          strokeLinejoin=\"round\"\n                          pathLength={1}\n                          strokeDasharray={1}\n                          style={{ strokeDashoffset: empty && expanded ? 0 : 1, transition: reduced ? \"none\" : `stroke-dashoffset 480ms ${EASE} 260ms` }}\n                        />\n                      </svg>\n                    </span>\n                    <p className=\"text-[13px] font-medium\">{tab === \"unread\" ? \"You're all caught up\" : \"Nothing here yet\"}</p>\n                    <p className=\"text-[12px] text-muted-foreground\">{tab === \"unread\" ? \"New notifications will show up here.\" : \"When something happens, you'll hear about it here.\"}</p>\n                  </div>\n                </div>\n              </div>\n\n              {loadMore && !noMore && tab === \"all\" && !empty && (\n                <div className=\"flex justify-center py-1.5\">\n                  <button type=\"button\" onClick={more} aria-busy={loading || undefined} className={`h-7 cursor-pointer rounded-full px-3 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground ${FOCUS}`}>\n                    <TextMorph>{loading ? \"Loading\" : \"Show older\"}</TextMorph>\n                  </button>\n                </div>\n              )}\n            </div>\n          </div>\n        </div>\n      )}\n\n      <span className=\"sr-only\" role=\"status\">\n        {announce}\n      </span>\n    </div>\n  );\n}\n"},{"path":"registry/relative-time.tsx","type":"registry:component","content":"// Hairline UI · notification-inbox · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { NumberRoll } from \"./number-roll\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * RELATIVE TIME: \"2 min ago\" that keeps itself true\n *\n *   tick      it re-renders only when the words would change:\n *             on the next minute under an hour, the next hour\n *             under a day; nothing runs between\n *   roll      the number rolls up (\"2 min\" → \"3 min\") and the\n *             unit morphs when it changes (\"59 min\" → \"1 h\")\n *   date      past a week it settles on the date (\"12 Sep\")\n *\n * Until it's mounted it renders the date, so the server and the\n * first paint agree; the hover title is always the full time.\n * ───────────────────────────────────────────────────────── */\n\nexport type DateLike = Date | string | number;\n\nexport interface RelativeTimeProps extends Omit<React.HTMLAttributes<HTMLElement>, \"children\"> {\n  date: DateLike;\n  /** Said before the time, e.g. \"Last used\". */\n  prefix?: string;\n  /** Under a minute. */\n  justNow?: string;\n  locales?: string | string[];\n}\n\nconst MINUTE = 60_000;\nconst HOUR = 60 * MINUTE;\nconst DAY = 24 * HOUR;\n\nexport const toTime = (d: DateLike) => (d instanceof Date ? d.getTime() : typeof d === \"number\" ? d : new Date(d).getTime());\n\nconst MONTHS = [\"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\", \"Jul\", \"Aug\", \"Sep\", \"Oct\", \"Nov\", \"Dec\"];\n\n/** \"12 Sep\", with the year when it isn't this one; pass locales to use the browser's own format. */\nexport function formatDate(d: DateLike, locales?: string | string[]) {\n  const t = new Date(toTime(d));\n  const sameYear = t.getFullYear() === new Date().getFullYear();\n  if (locales) return t.toLocaleDateString(locales, sameYear ? { day: \"numeric\", month: \"short\" } : { day: \"numeric\", month: \"short\", year: \"numeric\" });\n  return `${t.getDate()} ${MONTHS[t.getMonth()]}${sameYear ? \"\" : ` ${t.getFullYear()}`}`;\n}\n\ntype Parts = { n: number | null; unit: string; next: number };\n\nfunction parts(at: number, now: number, justNow: string, locales?: string | string[]): Parts {\n  const ago = Math.max(0, now - at);\n  if (ago < MINUTE) return { n: null, unit: justNow, next: MINUTE - ago };\n  if (ago < HOUR) return { n: Math.floor(ago / MINUTE), unit: \"min ago\", next: MINUTE - (ago % MINUTE) };\n  if (ago < DAY) {\n    const h = Math.floor(ago / HOUR);\n    return { n: h, unit: h === 1 ? \"hour ago\" : \"hours ago\", next: HOUR - (ago % HOUR) };\n  }\n  if (ago < 7 * DAY) {\n    const d = Math.floor(ago / DAY);\n    return { n: d, unit: d === 1 ? \"day ago\" : \"days ago\", next: DAY - (ago % DAY) };\n  }\n  return { n: null, unit: formatDate(at, locales), next: 0 };\n}\n\nconst subscribeMounted = () => () => {};\n\nexport function RelativeTime({ date, prefix, justNow = \"just now\", locales, className = \"\", ...props }: RelativeTimeProps) {\n  const at = toTime(date);\n  const mounted = React.useSyncExternalStore(subscribeMounted, () => true, () => false);\n  const [now, setNow] = React.useState(() => Date.now());\n  const p = parts(at, mounted ? now : at + 8 * DAY, justNow, locales);\n\n  // One timer, set for the moment the words change.\n  React.useEffect(() => {\n    if (!mounted || !p.next) return;\n    const timer = window.setTimeout(() => setNow(Date.now()), p.next + 50);\n    return () => window.clearTimeout(timer);\n  }, [mounted, p.next, now]);\n\n  React.useEffect(() => setNow(Date.now()), [at]);\n\n  const iso = new Date(at).toISOString();\n  return (\n    <time\n      dateTime={iso}\n      title={new Date(at).toLocaleString(locales ?? \"en-GB\", { dateStyle: \"medium\", timeStyle: \"short\" })}\n      className={`inline-flex items-baseline whitespace-nowrap tabular-nums ${className}`}\n      {...props}\n    >\n      {prefix && <span>{prefix}&nbsp;</span>}\n      {/* Remounted once on the client, so the first real words appear without a morph. */}\n      <Words key={mounted ? \"client\" : \"server\"} n={p.n} unit={p.unit} />\n    </time>\n  );\n}\n\nfunction Words({ n, unit }: { n: number | null; unit: string }) {\n  return (\n    <>\n      <span\n        aria-hidden={n === null || undefined}\n        className=\"inline-grid transition-[grid-template-columns] duration-[380ms] ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none\"\n        style={{ gridTemplateColumns: n === null ? \"0fr\" : \"1fr\" }}\n      >\n        <span className=\"min-w-0 [clip-path:inset(-4px_0)]\">\n          <span className=\"inline-flex items-baseline pr-[0.25em]\">\n            <NumberRoll value={n ?? 1} direction=\"up\" />\n          </span>\n        </span>\n      </span>\n      <TextMorph>{unit}</TextMorph>\n    </>\n  );\n}\n"},{"path":"registry/use-row-motion.ts","type":"registry:component","content":"// Hairline UI · notification-inbox · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * ROW MOTION: rows that keep their place on screen\n *\n *   glide     after any render (a sort, a filter, new data from\n *             the server) every row that moved glides from its\n *             old place to its new one. Glides are additive, so\n *             a change that lands mid-glide adds its own offset\n *             instead of restarting anything\n *   travel    a row that sorts onto the page slides in from the\n *             side where it ranked before; one that sorts off\n *             slides out toward its new rank\n *   enter     a row that's new to the list fades in where it\n *             lands while the rows below glide clear of it\n *   leave     a row that's gone folds away where it stood. Its\n *             copy is the row's own element, detached by React\n *             and moved into a layer React never renders into,\n *             so a leaving row costs no render at all\n *   stagger   a sort can ask for the rows to set off one after\n *             another, top first\n *\n * Positions are layout offsets (transforms ignored), kept after\n * every render and whenever the list changes size (a row\n * opening its detail), so they're never stale. Rows are found\n * by data-row-id; ranks are their places in the whole sorted\n * list, not just the page.\n * ───────────────────────────────────────────────────────── */\n\ntype Seen = { top: number; height: number; el: HTMLElement };\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst GLIDE = 480;\n\nexport function useRowMotion(\n  container: React.RefObject<HTMLElement | null>,\n  { reduced, resetKey, ranks, ghosts }: { reduced: boolean; resetKey?: string | number; ranks: ReadonlyMap<string, number>; ghosts: React.RefObject<HTMLElement | null> },\n) {\n  const seen = React.useRef(new Map<string, Seen>());\n  const lastRanks = React.useRef(ranks);\n  const lastKey = React.useRef(resetKey);\n  const staggerNext = React.useRef(false);\n\n  const read = React.useCallback(() => {\n    const out = new Map<string, Seen>();\n    container.current?.querySelectorAll<HTMLElement>(\"[data-row-id]\").forEach((el) => out.set(el.dataset.rowId!, { top: el.offsetTop, height: el.offsetHeight, el }));\n    return out;\n  }, [container]);\n\n  /** The row React just removed, back on screen where it stood, leaving: sliding toward where it went, or folding. */\n  const leave = (was: Seen, dy: number) => {\n    const layer = ghosts.current;\n    const el = was.el;\n    if (!layer || el.isConnected) return;\n    // No getAnimations() here (it flushes style for the whole page): the exit below is added last and replaces any\n    // glide still on the element.\n    el.removeAttribute(\"data-row-id\");\n    el.removeAttribute(\"tabindex\");\n    el.setAttribute(\"aria-hidden\", \"true\");\n    el.inert = true;\n    Object.assign(el.style, { position: \"absolute\", left: \"0\", right: \"0\", top: `${was.top}px`, height: `${was.height}px`, margin: \"0\", overflow: \"hidden\" });\n    layer.appendChild(el);\n    const a = el.animate(\n      dy\n        ? [\n            { transform: \"translateY(0px)\", opacity: 1, filter: \"blur(0px)\" },\n            { transform: `translateY(${dy}px)`, opacity: 0, filter: \"blur(3px)\" },\n          ]\n        : [\n            { opacity: 1, transform: \"none\", filter: \"blur(0px)\" },\n            { opacity: 0, transform: \"scale(0.985)\", filter: \"blur(4px)\" },\n          ],\n      { duration: dy ? 360 : 280, easing: EASE, fill: \"forwards\" },\n    );\n    a.onfinish = a.oncancel = () => el.remove();\n  };\n\n  React.useLayoutEffect(() => {\n    const el = container.current;\n    if (!el) return;\n    const before = seen.current;\n    const after = read();\n    const prevRanks = lastRanks.current;\n    seen.current = after;\n    lastRanks.current = ranks;\n    const stagger = staggerNext.current;\n    staggerNext.current = false;\n    if (lastKey.current !== resetKey) {\n      // A different page: the caller slides the whole body; nothing glides and nothing folds.\n      lastKey.current = resetKey;\n      return;\n    }\n    if (reduced || !before.size) return;\n\n    // A typical row's height. Rows that come onto or leave the page only hint at the way they went (a row and a\n    // quarter at most), so the list never shows holes while they travel.\n    const heights = [...after.values()].map((b) => b.height).sort((a, b) => a - b);\n    const rowH = heights[Math.floor(heights.length / 2)] ?? 48;\n    const clamp = (dy: number) => Math.max(-rowH * 1.25, Math.min(rowH * 1.25, dy));\n\n    let order = 0;\n    after.forEach((now, id) => {\n      const row = now.el;\n      const was = before.get(id);\n      const delay = stagger ? Math.min(order++ * 16, 200) : 0;\n      if (was) {\n        const dy = was.top - now.top;\n        if (Math.abs(dy) < 0.5) return;\n        row.animate([{ transform: `translateY(${dy}px)`, zIndex: 1 }, { transform: \"translateY(0px)\", zIndex: 1 }], { duration: GLIDE, delay, easing: EASE, composite: \"add\", fill: \"backwards\" });\n        return;\n      }\n      const from = prevRanks.get(id);\n      const to = ranks.get(id);\n      if (from !== undefined && to !== undefined) {\n        // It ranked somewhere off the page: it comes in from that side.\n        row.animate(\n          [\n            { transform: `translateY(${clamp((from - to) * rowH)}px)`, opacity: 0, filter: \"blur(3px)\" },\n            { transform: \"translateY(0px)\", opacity: 1, filter: \"blur(0px)\" },\n          ],\n          { duration: GLIDE, delay: delay / 2, easing: EASE, fill: \"backwards\" },\n        );\n        return;\n      }\n      row.animate(\n        [\n          { opacity: 0, filter: \"blur(4px)\" },\n          { opacity: 1, filter: \"blur(0px)\" },\n        ],\n        { duration: 420, delay: 90, easing: EASE, fill: \"backwards\" },\n      );\n    });\n\n    // Still in the list, just not on this page: its copy slides toward where it went. Gone for good: it folds.\n    before.forEach((was, id) => {\n      if (after.has(id)) return;\n      const from = prevRanks.get(id);\n      const to = ranks.get(id);\n      leave(was, from !== undefined && to !== undefined ? clamp((to - from) * rowH) || rowH : 0);\n    });\n  });\n\n  // The list changed size without a render of ours (a detail opening by transition): keep the positions true.\n  React.useEffect(() => {\n    const el = container.current;\n    if (!el) return;\n    const observer = new ResizeObserver(() => {\n      seen.current = read();\n    });\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [container, read]);\n\n  return {\n    /** The next change (a sort) sets the rows off one after another. */\n    stagger: () => {\n      staggerNext.current = true;\n    },\n  };\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/notification-inbox. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}