{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "notification-center",
  "type": "registry:block",
  "title": "Notification center",
  "description": "A home for updates with read state, grouped information, and animated disclosure.",
  "categories": [
    "blocks"
  ],
  "dependencies": [
    "@radix-ui/react-popover",
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/avatar.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/notification-center/notification-center.tsx",
      "content": "\"use client\";\n\nimport {\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  type CSSProperties,\n  type ReactNode,\n} from \"react\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useIsPresent,\n  useMotionValue,\n  useReducedMotion,\n  type AnimationPlaybackControls,\n  type HTMLMotionProps,\n  type TargetAndTransition,\n  type Transition,\n  type Variants,\n} from \"motion/react\";\nimport { Bell, Check, CheckCheck, CircleCheck, CircleDot, MessageCircle, TriangleAlert, X } from \"lucide-react\";\nimport * as PopoverPrimitive from \"@radix-ui/react-popover\";\nimport { Avatar } from \"../avatar/avatar\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\nexport interface NotificationItem {\n  id: string;\n  title: string;\n  description?: string;\n  time: string;\n  read?: boolean;\n  tone?: \"info\" | \"success\" | \"warning\";\n  /** A local portrait asset for person-generated updates. */\n  actor?: { name: string; photo: string };\n}\n\nexport interface NotificationCenterProps {\n  notifications: NotificationItem[];\n  label?: string;\n  onReadChange?: (notification: NotificationItem, read: boolean) => void;\n  onDismiss?: (notification: NotificationItem) => void;\n  open?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  avoidCollisions?: boolean;\n}\n\ntype View = \"all\" | \"unread\";\n\nconst enter: Transition = { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] };\nconst exitFast: Transition = { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] };\nconst instant: Transition = { duration: 0 };\nconst textIn: TargetAndTransition = { opacity: 0, y: \"0.3em\", filter: `blur(${motionTokens.blur.soft}px)` };\nconst textOut: TargetAndTransition = {\n  opacity: 0,\n  y: \"-0.3em\",\n  filter: `blur(${motionTokens.blur.subtle}px)`,\n  transition: exitFast,\n};\nconst iconIn: TargetAndTransition = { opacity: 0, scale: 0.6, filter: `blur(${motionTokens.blur.subtle}px)` };\nconst shown: TargetAndTransition = { opacity: 1, y: \"0em\", scale: 1, filter: \"blur(0px)\" };\nconst fadeOut: TargetAndTransition = { opacity: 0, transition: { duration: motionTokens.duration.instant } };\n/** Bulk actions cascade down the list, capped so the whole sweep stays under a quarter second. */\nconst cascade = (index: number) => Math.min(index * motionTokens.stagger.item, 0.2);\n/** Quick press, spring release: the resting transition carries the spring back after the press. */\nconst actionButton =\n  \"inline-flex h-[30px] cursor-pointer items-center gap-[5px] overflow-clip rounded-button border border-line bg-transparent px-3 py-0 text-(length:--text-xs) leading-none whitespace-nowrap text-ink transition-[scale,border-color,background] duration-[var(--duration-spring),var(--duration-fast),var(--duration-fast)] ease-[var(--ease-spring),var(--ease-standard),var(--ease-standard)] hover:border-line-strong hover:bg-surface active:scale-97 active:duration-instant active:ease-standard motion-reduce:transition-none motion-reduce:active:scale-100 max-[380px]:px-[7px]\";\nconst quietButton =\n  \"cursor-pointer border-0 bg-transparent text-(length:--text-xs) text-ink-2 transition-[scale,color] duration-[var(--duration-spring),var(--duration-fast)] ease-[var(--ease-spring),var(--ease-standard)] hover:text-ink active:scale-97 active:duration-instant active:ease-standard motion-reduce:transition-none motion-reduce:active:scale-100\";\nconst toneIcon = { info: \"text-accent\", success: \"text-green\", warning: \"text-orange\" } as const;\n\n/** Outgoing copies are hidden from assistive tech while they fade, so live text reads only the current value. */\nfunction Swap(props: HTMLMotionProps<\"span\">) {\n  const present = useIsPresent();\n  return <motion.span {...props} aria-hidden={present ? props[\"aria-hidden\"] : true} />;\n}\n\nfunction SwapText({ children, reduce }: { children: string; reduce: boolean | null }) {\n  return (\n    <span className=\"relative inline-block max-w-full align-top\">\n      <AnimatePresence mode=\"popLayout\" initial={false}>\n        <Swap\n          key={children}\n          className=\"block\"\n          initial={reduce ? { opacity: 0 } : textIn}\n          animate={shown}\n          exit={reduce ? fadeOut : textOut}\n          transition={reduce ? instant : enter}\n        >\n          {children}\n        </Swap>\n      </AnimatePresence>\n    </span>\n  );\n}\n\nconst rollVariants: Variants = {\n  enter: (direction: number) => ({\n    opacity: 0,\n    y: direction >= 0 ? \"0.7em\" : \"-0.7em\",\n    filter: `blur(${motionTokens.blur.subtle}px)`,\n  }),\n  center: { opacity: 1, y: \"0em\", filter: \"blur(0px)\" },\n  exit: (direction: number) => ({\n    opacity: 0,\n    y: direction >= 0 ? \"-0.7em\" : \"0.7em\",\n    filter: `blur(${motionTokens.blur.subtle}px)`,\n    transition: exitFast,\n  }),\n};\n\n/** Counts roll like an odometer: a higher number rises from below, a lower one drops from above. */\nfunction RollingCount({\n  value,\n  display = String(value),\n  reduce,\n}: {\n  value: number;\n  display?: string;\n  reduce: boolean | null;\n}) {\n  const [previous, setPrevious] = useState(value);\n  const [direction, setDirection] = useState(0);\n  if (value !== previous) {\n    setDirection(value > previous ? 1 : -1);\n    setPrevious(value);\n  }\n  return (\n    <span className=\"relative inline-grid align-top\">\n      <AnimatePresence mode=\"popLayout\" initial={false} custom={direction}>\n        <Swap\n          key={display}\n          className=\"block\"\n          custom={direction}\n          variants={reduce ? undefined : rollVariants}\n          initial={reduce ? { opacity: 0 } : \"enter\"}\n          animate={reduce ? { opacity: 1 } : \"center\"}\n          exit={reduce ? fadeOut : \"exit\"}\n          transition={reduce ? instant : { y: motionTokens.spring.snappy, opacity: exitFast, filter: exitFast }}\n        >\n          {display}\n        </Swap>\n      </AnimatePresence>\n    </span>\n  );\n}\n\n/** Follows the width of its content. After `morphKey` changes the width springs from the old size to the new one, then returns to auto, so a longer label grows the control instead of snapping it. */\nfunction MorphWidth({ reduce, morphKey, children }: { reduce: boolean | null; morphKey: string; children: ReactNode }) {\n  const frame = useRef<HTMLSpanElement>(null);\n  const content = useRef<HTMLSpanElement>(null);\n  const width = useMotionValue<number | \"auto\">(\"auto\");\n  const changedAt = useRef(0);\n  useLayoutEffect(() => {\n    changedAt.current = performance.now();\n  }, [morphKey]);\n  useEffect(() => {\n    const node = content.current;\n    if (!node || typeof ResizeObserver === \"undefined\") return;\n    let last: number | undefined;\n    let controls: AnimationPlaybackControls | undefined;\n    const settle = () => {\n      width.jump(\"auto\");\n      if (frame.current) frame.current.style.width = \"auto\";\n    };\n    const observer = new ResizeObserver(([entry]) => {\n      if (!entry) return;\n      const next = entry.borderBoxSize?.[0]?.inlineSize ?? node.offsetWidth;\n      const current = width.get();\n      const from = typeof current === \"number\" ? current : last;\n      last = next;\n      controls?.stop();\n      if (reduce || from === undefined || from === next || performance.now() - changedAt.current > 120) return settle();\n      // Pin the old width before this frame paints, then spring to the new one.\n      if (frame.current) frame.current.style.width = `${from}px`;\n      controls = animate(width, [from, next], { ...motionTokens.spring.morph, onComplete: settle });\n    });\n    observer.observe(node);\n    return () => {\n      observer.disconnect();\n      controls?.stop();\n    };\n  }, [width, reduce]);\n  return (\n    <motion.span ref={frame} className=\"inline-flex items-center\" style={{ width }}>\n      <span ref={content} className=\"relative inline-flex w-max items-center gap-[5px]\">\n        {children}\n      </span>\n    </motion.span>\n  );\n}\n\nfunction NotificationVisual({ item }: { item: NotificationItem }) {\n  if (item.actor) return <Avatar name={item.actor.name} src={item.actor.photo} size=\"md\" />;\n  return (\n    <span className={cn(\"grid size-9 flex-none place-items-center\", toneIcon[item.tone ?? \"info\"])} aria-hidden=\"true\">\n      {item.tone === \"warning\" ? (\n        <TriangleAlert size={18} strokeWidth={1.7} />\n      ) : item.tone === \"success\" ? (\n        <CircleCheck size={18} strokeWidth={1.7} />\n      ) : (\n        <MessageCircle size={18} strokeWidth={1.7} />\n      )}\n    </span>\n  );\n}\n\nexport function NotificationCenter({\n  notifications: initial,\n  label = \"Notifications\",\n  onReadChange,\n  onDismiss,\n  open,\n  onOpenChange,\n  avoidCollisions = true,\n}: NotificationCenterProps) {\n  const reduce = useReducedMotion();\n  const layoutId = useId();\n  const [internalOpen, setInternalOpen] = useState(false);\n  const [items, setItems] = useState(initial);\n  const [view, setView] = useState<View>(\"all\");\n  const [expandedId, setExpandedId] = useState<string | null>(null);\n  const [bulk, setBulk] = useState(false);\n  const itemRefs = useRef(new Map<string, HTMLButtonElement>());\n  const allTabRef = useRef<HTMLButtonElement>(null);\n  const unreadTabRef = useRef<HTMLButtonElement>(null);\n  const isOpen = open ?? internalOpen;\n  const unreadCount = useMemo(() => items.filter((item) => !item.read).length, [items]);\n  const readCount = items.length - unreadCount;\n  const visible = view === \"unread\" ? items.filter((item) => !item.read) : items;\n  const summary = unreadCount\n    ? `${unreadCount} update${unreadCount === 1 ? \"\" : \"s\"} waiting for you`\n    : \"You’re all caught up\";\n\n  function setOpen(next: boolean) {\n    if (open === undefined) setInternalOpen(next);\n    onOpenChange?.(next);\n  }\n\n  function toggleRead(item: NotificationItem) {\n    const next = !item.read;\n    if (next && view === \"unread\") {\n      const index = visible.findIndex((entry) => entry.id === item.id);\n      const nextId = visible[index + 1]?.id ?? visible[index - 1]?.id;\n      requestAnimationFrame(() => (nextId ? itemRefs.current.get(nextId)?.focus() : unreadTabRef.current?.focus()));\n    }\n    setBulk(false);\n    setItems((current) => current.map((entry) => (entry.id === item.id ? { ...entry, read: next } : entry)));\n    if (next && view === \"unread\") setExpandedId(null);\n    onReadChange?.(item, next);\n  }\n\n  function markAllRead() {\n    items.filter((item) => !item.read).forEach((item) => onReadChange?.(item, true));\n    setBulk(true);\n    setItems((current) => current.map((item) => ({ ...item, read: true })));\n    setExpandedId(null);\n    requestAnimationFrame(() => (view === \"unread\" ? unreadTabRef : allTabRef).current?.focus());\n  }\n\n  function dismiss(item: NotificationItem) {\n    const index = visible.findIndex((entry) => entry.id === item.id);\n    const nextId = visible[index + 1]?.id ?? visible[index - 1]?.id;\n    requestAnimationFrame(() => (nextId ? itemRefs.current.get(nextId)?.focus() : unreadTabRef.current?.focus()));\n    setBulk(false);\n    setItems((current) => current.filter((entry) => entry.id !== item.id));\n    if (expandedId === item.id) setExpandedId(null);\n    onDismiss?.(item);\n  }\n\n  function clearRead() {\n    items.filter((item) => item.read).forEach((item) => onDismiss?.(item));\n    setBulk(true);\n    setItems((current) => current.filter((item) => !item.read));\n    requestAnimationFrame(() => allTabRef.current?.focus());\n  }\n\n  const height: Transition = reduce ? instant : { height: motionTokens.spring.smooth, opacity: enter };\n\n  return (\n    <PopoverPrimitive.Root open={isOpen} onOpenChange={setOpen}>\n      <PopoverPrimitive.Trigger asChild>\n        {/* The trigger anchors the panel, so it gives press feedback with color only; scaling it would shift the panel. */}\n        <button\n          className=\"relative grid size-[42px] cursor-pointer place-items-center rounded-button border border-line bg-surface text-ink transition-[border-color,background] duration-fast ease-standard hover:border-line-strong hover:bg-hover active:border-line-strong active:bg-hover data-[state=open]:border-line-strong data-[state=open]:bg-hover motion-reduce:transition-none [&>span:first-child]:grid [&>span:first-child]:place-items-center\"\n          type=\"button\"\n          aria-label={`${label}${unreadCount ? `, ${unreadCount} unread` : \"\"}`}\n        >\n          <motion.span\n            animate={{ rotate: isOpen && !reduce ? -12 : 0 }}\n            transition={reduce ? instant : motionTokens.spring.snappy}\n          >\n            <Bell size={19} strokeWidth={1.75} aria-hidden=\"true\" />\n          </motion.span>\n          <AnimatePresence initial={false}>\n            {unreadCount > 0 && (\n              <motion.span\n                key=\"badge\"\n                className=\"absolute -top-1.5 -right-1.5 grid h-5 min-w-5 place-items-center overflow-hidden rounded-full border-2 border-surface bg-ink px-[5px] py-0 text-(length:--text-xs) leading-none font-medium text-page tabular-nums\"\n                aria-hidden=\"true\"\n                initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}\n                animate={{ opacity: 1, scale: 1 }}\n                exit={reduce ? fadeOut : { opacity: 0, scale: 0.6, transition: exitFast }}\n                transition={reduce ? instant : motionTokens.spring.snappy}\n              >\n                <RollingCount\n                  value={unreadCount}\n                  display={unreadCount > 9 ? \"9+\" : String(unreadCount)}\n                  reduce={reduce}\n                />\n              </motion.span>\n            )}\n          </AnimatePresence>\n        </button>\n      </PopoverPrimitive.Trigger>\n      <PopoverPrimitive.Portal>\n        <PopoverPrimitive.Content\n          className=\"z-90 flex max-h-[min(590px,calc(100vh_-_24px))] w-[min(424px,calc(100vw_-_24px))] origin-(--radix-popover-content-transform-origin) flex-col overflow-hidden rounded-panel border border-line bg-surface font-body tracking-body text-ink shadow-floating [--panel-x:0px] [--panel-y:-6px] data-[side=left]:[--panel-x:6px] data-[side=left]:[--panel-y:0px] data-[side=right]:[--panel-x:-6px] data-[side=right]:[--panel-y:0px] data-[side=top]:[--panel-y:6px] data-[state=closed]:animate-notification-center-panel-close data-[state=open]:animate-notification-center-panel-open motion-reduce:data-state:animate-none\"\n          align=\"center\"\n          side=\"bottom\"\n          sideOffset={12}\n          collisionPadding={12}\n          avoidCollisions={avoidCollisions}\n          aria-label={label}\n        >\n          <div className=\"flex items-start justify-between gap-3 px-6 pt-[23px] pb-[18px] max-[380px]:px-4 max-[380px]:pt-5 max-[380px]:pb-4\">\n            <div>\n              <div className=\"flex items-center gap-2\">\n                <h2 className=\"m-0 text-(length:--text-xl) leading-[1.2] font-medium tracking-body max-[380px]:text-(length:--text-lg)\">\n                  {label}\n                </h2>\n                <span className=\"grid h-[21px] min-w-[21px] place-items-center overflow-hidden rounded-[7px] bg-hover px-[5px] py-0 text-(length:--text-xs) leading-none text-ink-2 tabular-nums\">\n                  <RollingCount value={unreadCount} reduce={reduce} />\n                </span>\n              </div>\n              <p className=\"relative mx-0 mt-1 mb-0 text-(length:--text-xs) leading-body text-ink-2\" aria-live=\"polite\">\n                <SwapText reduce={reduce}>{summary}</SwapText>\n              </p>\n            </div>\n            <PopoverPrimitive.Close\n              className=\"grid size-[30px] flex-none cursor-pointer place-items-center rounded-button border-0 bg-transparent text-ink-3 transition-[scale,color,background] duration-[var(--duration-spring),var(--duration-fast),var(--duration-fast)] ease-[var(--ease-spring),var(--ease-standard),var(--ease-standard)] hover:bg-hover hover:text-ink active:scale-96 active:duration-instant active:ease-standard motion-reduce:transition-none motion-reduce:active:scale-100\"\n              aria-label=\"Close notifications\"\n            >\n              <X size={17} strokeWidth={1.75} aria-hidden=\"true\" />\n            </PopoverPrimitive.Close>\n          </div>\n\n          <div className=\"flex min-h-[52px] items-center justify-between gap-3 border-b border-line-soft pt-0 pr-[17px] pb-[13px] pl-5 max-[380px]:items-start max-[380px]:px-3 max-[380px]:pb-3\">\n            <div className=\"inline-flex items-center gap-[3px]\" role=\"group\" aria-label=\"Show notifications\">\n              {([\"all\", \"unread\"] as const).map((next) => (\n                <button\n                  key={next}\n                  ref={next === \"unread\" ? unreadTabRef : allTabRef}\n                  type=\"button\"\n                  className={cn(\n                    \"relative isolate h-[31px] min-w-[61px] cursor-pointer rounded-button border-0 bg-transparent px-3 py-0 text-(length:--text-xs) transition-[color] duration-fast ease-standard hover:text-ink motion-reduce:transition-none [&>span:last-child]:relative [&>span:last-child]:z-1\",\n                    view === next ? \"text-ink\" : \"text-ink-2\",\n                  )}\n                  aria-pressed={view === next}\n                  onClick={() => {\n                    setBulk(false);\n                    setView(next);\n                    setExpandedId(null);\n                  }}\n                >\n                  {view === next && (\n                    <motion.span\n                      className=\"absolute inset-0 -z-1 rounded-button bg-hover\"\n                      layoutId={`${layoutId}-view`}\n                      transition={reduce ? instant : motionTokens.spring.morph}\n                    />\n                  )}\n                  <span>{next === \"all\" ? \"All\" : \"Unread\"}</span>\n                </button>\n              ))}\n            </div>\n            <AnimatePresence initial={false}>\n              {unreadCount > 0 && (\n                <motion.button\n                  key=\"mark-all\"\n                  type=\"button\"\n                  className={cn(\n                    quietButton,\n                    \"inline-flex items-center gap-1.5 px-0 py-1.5 whitespace-nowrap max-[380px]:[&_span]:hidden\",\n                  )}\n                  onClick={markAllRead}\n                  initial={reduce ? { opacity: 0 } : { opacity: 0, filter: `blur(${motionTokens.blur.subtle}px)` }}\n                  animate={{ opacity: 1, filter: \"blur(0px)\" }}\n                  exit={\n                    reduce\n                      ? fadeOut\n                      : { opacity: 0, filter: `blur(${motionTokens.blur.subtle}px)`, transition: exitFast }\n                  }\n                  transition={reduce ? instant : enter}\n                >\n                  <CheckCheck size={15} strokeWidth={1.75} aria-hidden=\"true\" />\n                  <span>Mark all read</span>\n                </motion.button>\n              )}\n            </AnimatePresence>\n          </div>\n\n          {/* Rows collapse their own height on the way out, so the list and the panel close the gap together. */}\n          <div\n            className=\"min-h-0 overflow-y-auto overscroll-contain px-[11px] pt-[9px] pb-2.5 [scrollbar-width:none] max-[380px]:px-[5px] max-[380px]:py-2 [&::-webkit-scrollbar]:hidden\"\n            role=\"list\"\n            aria-label={view === \"all\" ? \"All notifications\" : \"Unread notifications\"}\n          >\n            <AnimatePresence initial={false} custom={bulk}>\n              {visible.map((item, index) => (\n                <motion.div\n                  key={item.id}\n                  role=\"listitem\"\n                  className=\"overflow-hidden\"\n                  custom={bulk}\n                  variants={{\n                    exit: (isBulk: boolean) =>\n                      reduce\n                        ? fadeOut\n                        : {\n                            height: 0,\n                            opacity: 0,\n                            transition: {\n                              height: { ...motionTokens.spring.smooth, delay: isBulk ? cascade(index) : 0 },\n                              opacity: { ...exitFast, delay: isBulk ? cascade(index) : 0 },\n                            },\n                          },\n                  }}\n                  initial={reduce ? { opacity: 0 } : { height: 0 }}\n                  animate={{ height: \"auto\", opacity: 1 }}\n                  exit=\"exit\"\n                  transition={height}\n                >\n                  <article\n                    className={cn(\n                      \"relative animate-notification-center-item-in rounded-[calc(var(--radius-panel)_-_12px)] p-2.5 transition-[background] duration-fast ease-standard hover:bg-hover max-[380px]:px-[7px] motion-reduce:animate-none motion-reduce:transition-none\",\n                      expandedId === item.id && \"bg-hover\",\n                    )}\n                    style={{ \"--index\": Math.min(index, 7) } as CSSProperties}\n                  >\n                    <div className=\"flex min-w-0 items-start gap-[11px] max-[380px]:gap-[7px]\">\n                      <NotificationVisual item={item} />\n                      <button\n                        ref={(node) => {\n                          if (node) itemRefs.current.set(item.id, node);\n                          else itemRefs.current.delete(item.id);\n                        }}\n                        type=\"button\"\n                        className=\"grid min-w-0 flex-1 cursor-pointer gap-1 border-0 bg-transparent px-0 pt-0.5 pb-0 text-left text-ink\"\n                        aria-expanded={expandedId === item.id}\n                        aria-label={`${item.title}${item.read ? \"\" : \", unread\"}. ${expandedId === item.id ? \"Hide details\" : \"Show details\"}`}\n                        onClick={() => setExpandedId((current) => (current === item.id ? null : item.id))}\n                      >\n                        <span className=\"flex min-h-5 min-w-0 items-center gap-1.5\">\n                          <strong\n                            className={cn(\n                              \"overflow-hidden text-(length:--text-sm) leading-[1.4] font-medium text-ellipsis whitespace-nowrap\",\n                              item.read && \"text-ink-2\",\n                            )}\n                          >\n                            {item.title}\n                          </strong>\n                          <AnimatePresence initial={false} custom={bulk}>\n                            {!item.read && (\n                              <motion.span\n                                key=\"dot\"\n                                className=\"block size-1.5 flex-none rounded-[50%] bg-ink\"\n                                aria-hidden=\"true\"\n                                custom={bulk}\n                                variants={{\n                                  exit: (isBulk: boolean) =>\n                                    reduce\n                                      ? fadeOut\n                                      : {\n                                          opacity: 0,\n                                          scale: 0.3,\n                                          transition: { ...exitFast, delay: isBulk ? cascade(index) : 0 },\n                                        },\n                                }}\n                                initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.3 }}\n                                animate={{ opacity: 1, scale: 1 }}\n                                exit=\"exit\"\n                                transition={reduce ? instant : motionTokens.spring.snappy}\n                              />\n                            )}\n                          </AnimatePresence>\n                        </span>\n                        <span className=\"overflow-hidden text-(length:--text-xs) leading-body text-ellipsis whitespace-nowrap text-ink-2\">\n                          {item.description ?? (item.actor ? `From ${item.actor.name}` : \"View update details\")}\n                        </span>\n                      </button>\n                      <time className=\"flex-none pt-1 text-(length:--text-xs) leading-body whitespace-nowrap text-ink-3 tabular-nums\">\n                        {item.time}\n                      </time>\n                    </div>\n                    <AnimatePresence initial={false}>\n                      {expandedId === item.id && (\n                        <motion.div\n                          className=\"overflow-hidden pl-[47px] max-[380px]:pl-[43px]\"\n                          initial={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}\n                          animate={{ height: \"auto\", opacity: 1 }}\n                          exit={\n                            reduce\n                              ? fadeOut\n                              : {\n                                  height: 0,\n                                  opacity: 0,\n                                  transition: { height: motionTokens.spring.smooth, opacity: exitFast },\n                                }\n                          }\n                          transition={height}\n                        >\n                          <p className=\"mx-0 my-3 text-(length:--text-xs) leading-body text-ink-2\">\n                            {item.description ??\n                              (item.actor\n                                ? `${item.actor.name} shared an update with you.`\n                                : \"This update is ready to review.\")}\n                          </p>\n                          <div className=\"flex flex-wrap gap-1.5 pb-[5px] max-[380px]:gap-[5px]\">\n                            <button\n                              type=\"button\"\n                              className={actionButton}\n                              aria-label={item.read ? \"Mark unread\" : \"Mark read\"}\n                              onClick={() => toggleRead(item)}\n                            >\n                              <MorphWidth reduce={reduce} morphKey={item.read ? \"read\" : \"unread\"}>\n                                <span className=\"relative grid place-items-center\">\n                                  <AnimatePresence mode=\"popLayout\" initial={false}>\n                                    <Swap\n                                      key={item.read ? \"unread\" : \"read\"}\n                                      className=\"relative grid place-items-center\"\n                                      initial={reduce ? { opacity: 0 } : iconIn}\n                                      animate={shown}\n                                      exit={reduce ? fadeOut : { ...iconIn, transition: exitFast }}\n                                      transition={reduce ? instant : motionTokens.spring.snappy}\n                                    >\n                                      {item.read ? (\n                                        <CircleDot size={14} strokeWidth={1.75} aria-hidden=\"true\" />\n                                      ) : (\n                                        <Check size={14} strokeWidth={1.75} aria-hidden=\"true\" />\n                                      )}\n                                    </Swap>\n                                  </AnimatePresence>\n                                </span>\n                                <SwapText reduce={reduce}>{item.read ? \"Mark unread\" : \"Mark read\"}</SwapText>\n                              </MorphWidth>\n                            </button>\n                            <button type=\"button\" className={actionButton} onClick={() => dismiss(item)}>\n                              <X size={14} strokeWidth={1.75} aria-hidden=\"true\" />\n                              Dismiss\n                            </button>\n                          </div>\n                        </motion.div>\n                      )}\n                    </AnimatePresence>\n                  </article>\n                </motion.div>\n              ))}\n              {/* The empty state opens its height on the same spring the rows close on, so the panel morphs between them instead of stacking both. After a bulk action it waits for the cascade to get going, so the panel never grows before it shrinks. */}\n              {visible.length === 0 && (\n                <motion.div\n                  key=\"empty\"\n                  className=\"overflow-hidden\"\n                  initial={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}\n                  animate={{ height: \"auto\", opacity: 1 }}\n                  exit={\n                    reduce\n                      ? fadeOut\n                      : { height: 0, opacity: 0, transition: { height: motionTokens.spring.smooth, opacity: exitFast } }\n                  }\n                  transition={\n                    reduce\n                      ? instant\n                      : {\n                          height: { ...motionTokens.spring.smooth, delay: bulk ? cascade(3) : 0 },\n                          opacity: { ...enter, delay: motionTokens.duration.fast },\n                        }\n                  }\n                >\n                  <motion.div\n                    className=\"grid min-h-[190px] content-center justify-items-center px-3 py-[22px] text-center\"\n                    initial={reduce ? false : { y: 6 }}\n                    animate={{ y: 0 }}\n                    transition={reduce ? instant : { ...enter, delay: motionTokens.duration.fast }}\n                  >\n                    <CircleCheck size={24} strokeWidth={1.5} className=\"mb-[13px] text-ink-3\" aria-hidden=\"true\" />\n                    <strong className=\"text-(length:--text-sm) font-medium\">\n                      <SwapText reduce={reduce}>{view === \"unread\" ? \"Nothing unread\" : \"All clear\"}</SwapText>\n                    </strong>\n                    <p className=\"mx-0 mt-1 mb-0 text-(length:--text-xs) text-ink-2\">\n                      <SwapText reduce={reduce}>\n                        {view === \"unread\" ? \"You’ve seen every update.\" : \"New updates will appear here.\"}\n                      </SwapText>\n                    </p>\n                    {view === \"unread\" && items.length > 0 && (\n                      <button\n                        type=\"button\"\n                        className=\"mt-4 cursor-pointer border-0 bg-transparent p-0 text-(length:--text-xs) text-ink underline underline-offset-3\"\n                        onClick={() => {\n                          setBulk(false);\n                          setView(\"all\");\n                        }}\n                      >\n                        View all updates\n                      </button>\n                    )}\n                  </motion.div>\n                </motion.div>\n              )}\n            </AnimatePresence>\n          </div>\n\n          <AnimatePresence initial={false}>\n            {readCount > 0 && (\n              <motion.div\n                key=\"footer\"\n                className=\"flex-none overflow-hidden\"\n                initial={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}\n                animate={{ height: \"auto\", opacity: 1 }}\n                exit={\n                  reduce\n                    ? fadeOut\n                    : { height: 0, opacity: 0, transition: { height: motionTokens.spring.smooth, opacity: exitFast } }\n                }\n                transition={height}\n              >\n                <div className=\"flex min-h-[46px] items-center justify-between gap-3 border-t border-line-soft px-[22px] py-2 text-(length:--text-xs) text-ink-3 max-[380px]:px-4\">\n                  <span>\n                    <RollingCount value={readCount} reduce={reduce} /> read\n                  </span>\n                  <button type=\"button\" className={cn(quietButton, \"px-0 py-1\")} onClick={clearRead}>\n                    Clear read\n                  </button>\n                </div>\n              </motion.div>\n            )}\n          </AnimatePresence>\n        </PopoverPrimitive.Content>\n      </PopoverPrimitive.Portal>\n    </PopoverPrimitive.Root>\n  );\n}\n\nexport default NotificationCenter;\n",
      "type": "registry:component",
      "target": "@components/iris/notification-center/notification-center.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/notification-center",
  "meta": {
    "kind": "block",
    "group": "Feedback",
    "export": "NotificationCenter",
    "docs": "https://irisui.dev/components/notification-center"
  }
}
