{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "announcement-bar",
  "type": "registry:ui",
  "title": "Announcement Bar",
  "description": "Slim top bar that rotates messages, holds while read and can be paused.",
  "categories": [
    "notifications"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/announcement-bar.tsx",
      "content": "\"use client\";\nimport { AnimatePresence, motion, useMotionValue, useReducedMotion, type MotionValue } from \"motion/react\";\nimport { useCallback, useEffect, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ArrowRight, Pause, Play, X } from \"@/lib/icons\";\nimport { ease } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type Announcement = {\n  id: string;\n  content: React.ReactNode;\n  /** Makes the whole message a link. */\n  href?: string;\n  /** Short call to action after the message, like \"Read the changelog\". Hidden on narrow bars. */\n  cta?: string;\n};\n\nexport type AnnouncementBarProps = Omit<React.ComponentProps<\"section\">, \"children\"> & {\n  messages: Announcement[];\n  /** Milliseconds each message stays before the next. */\n  interval?: number;\n  /** Rotate on its own. The person can still pause it. */\n  autoPlay?: boolean;\n  index?: number;\n  defaultIndex?: number;\n  onIndexChange?: (index: number) => void;\n  variant?: \"subtle\" | \"inverted\";\n  dismissible?: boolean;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Remember the dismissal on this device. Change the key when the messages change. */\n  storageKey?: string;\n  /** Accessible name of the region. */\n  label?: string;\n};\n\n// Remembered dismissal, read after hydration so server and client agree.\nconst PREFIX = \"stealth-announcement:\";\nconst EVENT = \"stealth-announcement-change\";\nconst readDismissed = (key: string) => {\n  try {\n    return window.localStorage.getItem(PREFIX + key) === \"1\";\n  } catch {\n    return false;\n  }\n};\nconst subscribe = (fn: () => void) => {\n  window.addEventListener(\"storage\", fn);\n  window.addEventListener(EVENT, fn);\n  return () => {\n    window.removeEventListener(\"storage\", fn);\n    window.removeEventListener(EVENT, fn);\n  };\n};\n\n/** Clear a remembered dismissal, so the bar shows again. */\nexport function resetAnnouncementBar(storageKey: string) {\n  try {\n    window.localStorage.removeItem(PREFIX + storageKey);\n  } catch {}\n  window.dispatchEvent(new Event(EVENT));\n}\n\nexport function AnnouncementBar({\n  messages,\n  interval = 5000,\n  autoPlay = true,\n  index: indexProp,\n  defaultIndex = 0,\n  onIndexChange,\n  variant = \"subtle\",\n  dismissible = false,\n  open: openProp,\n  defaultOpen = true,\n  onOpenChange,\n  storageKey,\n  label = \"Announcements\",\n  className,\n  ...rest\n}: AnnouncementBarProps) {\n  const reduce = useReducedMotion();\n  const count = messages.length;\n  const [rawIndex, setIndex] = useControllableState({ value: indexProp, defaultValue: defaultIndex, onChange: onIndexChange });\n  const index = count ? ((rawIndex % count) + count) % count : 0;\n  const [open, setOpen] = useControllableState({ value: openProp, defaultValue: defaultOpen, onChange: onOpenChange });\n  const remembered = useSyncExternalStore(subscribe, () => (storageKey ? readDismissed(storageKey) : false), () => false);\n\n  const [paused, setPaused] = useState(false);\n  const [hovered, setHovered] = useState(false);\n  const [focused, setFocused] = useState(false);\n  const [inView, setInView] = useState(true);\n  const [dir, setDir] = useState(1);\n  const ref = useRef<HTMLElement>(null);\n  const progress = useMotionValue(0);\n\n  const rotating = autoPlay && count > 1;\n  // Waits while it is being read, pointed at, or can't be seen.\n  const running = rotating && !paused && !hovered && !focused && inView;\n\n  const go = useCallback(\n    (next: number, direction: number) => {\n      setDir(direction);\n      progress.set(0);\n      setIndex(((next % count) + count) % count);\n    },\n    [count, progress, setIndex],\n  );\n\n  // The clock: accumulates only while running, so pausing keeps the place in the current message.\n  const advance = useRef(() => {});\n  useEffect(() => {\n    advance.current = () => go(index + 1, 1);\n  });\n  useEffect(() => {\n    if (!running) return;\n    let raf = 0;\n    let last = performance.now();\n    const tick = (now: number) => {\n      const delta = Math.min(now - last, 50);\n      last = now;\n      if (document.visibilityState === \"visible\") {\n        const next = progress.get() + delta / interval;\n        if (next >= 1) advance.current();\n        else progress.set(next);\n      }\n      raf = requestAnimationFrame(tick);\n    };\n    raf = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(raf);\n  }, [running, interval, progress]);\n\n  // Off screen, nothing runs.\n  useEffect(() => {\n    const node = ref.current;\n    if (!node || !rotating) return;\n    const io = new IntersectionObserver(([entry]) => setInView(entry.isIntersecting));\n    io.observe(node);\n    return () => io.disconnect();\n  }, [rotating]);\n\n  const dismiss = () => {\n    if (storageKey) {\n      try {\n        window.localStorage.setItem(PREFIX + storageKey, \"1\");\n      } catch {}\n      window.dispatchEvent(new Event(EVENT));\n    }\n    setOpen(false);\n  };\n\n  const inverted = variant === \"inverted\";\n  const message = messages[index];\n  const shown = open && !remembered && count > 0;\n\n  return (\n    <AnimatePresence initial={false}>\n      {shown && (\n        <motion.div\n          key=\"bar\"\n          className=\"shrink-0 overflow-hidden\"\n          initial={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}\n          animate={{ height: \"auto\", opacity: 1 }}\n          exit={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}\n          transition={reduce ? { duration: 0.15 } : { height: { duration: 0.26, ease: ease.inOut }, opacity: { duration: 0.14 } }}\n        >\n          <section\n            ref={ref}\n            aria-label={label}\n            aria-roledescription={rotating ? \"carousel\" : undefined}\n            data-variant={variant}\n            data-state={running ? \"playing\" : \"paused\"}\n            onPointerEnter={(e) => e.pointerType === \"mouse\" && setHovered(true)}\n            onPointerLeave={() => setHovered(false)}\n            onFocus={() => setFocused(true)}\n            onBlur={(e) => !e.currentTarget.contains(e.relatedTarget as Node | null) && setFocused(false)}\n            className={cn(\n              \"@container relative grid h-9 grid-cols-[minmax(0,1fr)_minmax(0,max-content)_1fr] items-center gap-2 px-2 text-[12.5px]\",\n              inverted ? \"bg-fg text-frame\" : \"border-b border-line bg-raised text-fg\",\n              className,\n            )}\n            {...rest}\n          >\n            <span aria-hidden />\n\n            {/* One message at a time, in a fixed-height window. Announced only when the person is in control. */}\n            <div className=\"relative h-9 min-w-0 overflow-hidden\" aria-live={running ? \"off\" : \"polite\"} aria-atomic=\"true\">\n              <AnimatePresence initial={false} mode=\"popLayout\" custom={dir}>\n                <motion.div\n                  key={message.id}\n                  custom={dir}\n                  className=\"flex h-9 min-w-0 items-center justify-center\"\n                  variants={{\n                    enter: (d: number) => (reduce ? { opacity: 0 } : { opacity: 0, y: d * 14, filter: \"blur(2px)\" }),\n                    center: { opacity: 1, y: 0, filter: \"blur(0px)\", transition: { duration: 0.34, ease: ease.out } },\n                    exit: (d: number) => (reduce ? { opacity: 0, transition: { duration: 0.15 } } : { opacity: 0, y: d * -14, filter: \"blur(2px)\", transition: { duration: 0.22, ease: ease.out } }),\n                  }}\n                  initial=\"enter\"\n                  animate=\"center\"\n                  exit=\"exit\"\n                  role={rotating ? \"group\" : undefined}\n                  aria-roledescription={rotating ? \"slide\" : undefined}\n                  aria-label={rotating ? `${index + 1} of ${count}` : undefined}\n                >\n                  <Message message={message} inverted={inverted} />\n                </motion.div>\n              </AnimatePresence>\n            </div>\n\n            <div className=\"flex items-center justify-end gap-0.5\">\n              {rotating && (\n                <>\n                  <div className=\"flex items-center @max-[420px]:hidden\" aria-label=\"Choose an announcement\" role=\"group\">\n                    {messages.map((m, i) => (\n                      <Segment\n                        key={m.id}\n                        state={i < index ? \"done\" : i === index ? \"current\" : \"todo\"}\n                        progress={progress}\n                        inverted={inverted}\n                        label={`Show announcement ${i + 1} of ${count}`}\n                        onClick={() => i !== index && go(i, i > index ? 1 : -1)}\n                      />\n                    ))}\n                  </div>\n                  <IconButton inverted={inverted} label={paused ? \"Play announcements\" : \"Pause announcements\"} onClick={() => setPaused((p) => !p)}>\n                    <AnimatePresence initial={false} mode=\"popLayout\">\n                      <motion.span\n                        key={paused ? \"play\" : \"pause\"}\n                        className=\"grid place-items-center\"\n                        initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}\n                        animate={{ opacity: 1, scale: 1 }}\n                        exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}\n                        transition={{ duration: 0.16, ease: ease.out }}\n                      >\n                        {paused ? <Play size={12} /> : <Pause size={12} />}\n                      </motion.span>\n                    </AnimatePresence>\n                  </IconButton>\n                </>\n              )}\n              {dismissible && (\n                <IconButton inverted={inverted} label=\"Dismiss announcements\" onClick={dismiss}>\n                  <X size={14} />\n                </IconButton>\n              )}\n            </div>\n          </section>\n        </motion.div>\n      )}\n    </AnimatePresence>\n  );\n}\n\nfunction Message({ message, inverted }: { message: Announcement; inverted: boolean }) {\n  const body = (\n    <>\n      <span className=\"truncate\">{message.content}</span>\n      {message.cta && (\n        <span className={cn(\"shrink-0 font-medium @max-[520px]:hidden\", inverted ? \"text-frame\" : \"text-fg\")}>\n          <span aria-hidden className={cn(\"mx-2\", inverted ? \"opacity-40\" : \"text-fg-4\")}>\n            ·\n          </span>\n          {message.cta}\n        </span>\n      )}\n    </>\n  );\n  if (!message.href) return <span className={cn(\"flex min-w-0 items-center\", inverted ? \"text-frame/80\" : \"text-fg-2\")}>{body}</span>;\n  return (\n    <a\n      href={message.href}\n      className={cn(\n        \"group/msg flex min-w-0 items-center rounded-md px-1.5 py-1 outline-none transition-colors duration-150\",\n        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-0\",\n        inverted ? \"text-frame/80 hover:text-frame focus-visible:outline-frame/60\" : \"text-fg-2 hover:text-fg focus-visible:outline-fg-3\",\n      )}\n    >\n      {body}\n      <ArrowRight\n        size={14}\n        className={cn(\n          \"ml-1 shrink-0 transition-[translate,opacity] duration-200 ease-out-expo group-hover/msg:translate-x-0.5\",\n          inverted ? \"opacity-70 group-hover/msg:opacity-100\" : \"text-fg-3 group-hover/msg:text-fg\",\n        )}\n      />\n    </a>\n  );\n}\n\n// A 2px track that fills while its message is showing. The hit area is the full bar height.\nfunction Segment({ state, progress, inverted, label, onClick }: { state: \"done\" | \"current\" | \"todo\"; progress: MotionValue<number>; inverted: boolean; label: string; onClick: () => void }) {\n  return (\n    <button\n      type=\"button\"\n      aria-label={label}\n      aria-current={state === \"current\" ? \"true\" : undefined}\n      onClick={onClick}\n      className=\"group/seg grid h-9 w-5 place-items-center outline-none\"\n    >\n      <span\n        className={cn(\n          \"relative h-0.5 w-3.5 overflow-hidden rounded-full transition-[width,background-color] duration-200\",\n          inverted ? \"bg-frame/20 group-hover/seg:bg-frame/35\" : \"bg-fg/10 group-hover/seg:bg-fg/20\",\n          \"group-focus-visible/seg:outline-solid group-focus-visible/seg:outline-1 group-focus-visible/seg:outline-offset-4\",\n          inverted ? \"group-focus-visible/seg:outline-frame/60\" : \"group-focus-visible/seg:outline-fg-3\",\n        )}\n      >\n        <motion.span className={cn(\"absolute inset-0 origin-left\", inverted ? \"bg-frame\" : \"bg-fg/70\")} style={{ scaleX: state === \"current\" ? progress : state === \"done\" ? 1 : 0 }} />\n      </span>\n    </button>\n  );\n}\n\nfunction IconButton({ inverted, label, onClick, children }: { inverted: boolean; label: string; onClick: () => void; children: React.ReactNode }) {\n  return (\n    <button\n      type=\"button\"\n      aria-label={label}\n      title={label}\n      onClick={onClick}\n      className={cn(\n        \"relative grid size-7 shrink-0 place-items-center rounded-md outline-none\",\n        \"transition-[background-color,color,scale] duration-150 ease-out active:scale-[0.9] active:duration-75\",\n        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-0\",\n        \"before:absolute before:-inset-2 before:content-[''] pointer-fine:before:hidden\",\n        inverted ? \"text-frame/70 hover:bg-frame/10 hover:text-frame focus-visible:outline-frame/60\" : \"text-fg-3 hover:bg-hover hover:text-fg focus-visible:outline-fg-3\",\n      )}\n    >\n      {children}\n    </button>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/announcement-bar"
}
