{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "announcement-bar",
  "type": "registry:ui",
  "title": "Announcement bar",
  "description": "A top banner that rotates messages, counts down, and collapses smoothly when dismissed.",
  "categories": [
    "feedback"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/announcement-bar/announcement-bar.tsx",
      "type": "registry:component",
      "target": "@components/xzero/announcement-bar/announcement-bar.tsx",
      "content": "\"use client\";\n\nimport { forwardRef, useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport type { CSSProperties, ReactNode } from \"react\";\nimport { AnimatePresence, animate, motion, useIsPresent, useMotionValue, useReducedMotion, useTransform } from \"motion/react\";\nimport type { AnimationPlaybackControls, Transition, Variants } from \"motion/react\";\nimport { ArrowRight, ChevronDown, ChevronUp, Pause, Play, X } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./announcement-bar.module.css\";\n\nexport interface AnnouncementAction {\n  label: string;\n  href?: string;\n  onClick?: () => void;\n}\n\nexport interface Announcement {\n  /** Stable id, reported to callbacks. */\n  id: string;\n  message: ReactNode;\n  action?: AnnouncementAction;\n  /** Counts down to a moment, for example the end of a sale. */\n  countdown?: { to: Date | string | number; label?: string };\n}\n\n/**\n * A slim bar for the top of a page. It can rotate several messages, each rising in from below while the bar springs to\n * the new height; rotation pauses on hover, focus, or a hidden tab, and `controls` adds previous, next, and a pause ring. Messages can carry a\n * call to action and a live countdown. Dismissing collapses the height smoothly so the page below eases up, and with an\n * `id` the dismissal is remembered in `localStorage`.\n */\nexport interface AnnouncementBarProps {\n  messages: Announcement[];\n  /** Remembers dismissal under this id. Change the id to show a new campaign to everyone again. */\n  id?: string;\n  /** Whether the bar is shown. Leave it out to let the component manage it. */\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Index of the visible message. */\n  index?: number;\n  defaultIndex?: number;\n  onIndexChange?: (index: number) => void;\n  /** Milliseconds each message stays before the next one. Defaults to 6000. */\n  interval?: number;\n  /** Rotate automatically. Pauses on hover, focus, or a hidden tab, and is turned off when the visitor prefers reduced motion. Defaults to true. */\n  autoPlay?: boolean;\n  /** Show previous, next, and pause controls when there are several messages. Defaults to false: only the close button shows. */\n  controls?: boolean;\n  dismissible?: boolean;\n  tone?: \"neutral\" | \"inverted\";\n  onAction?: (announcement: Announcement) => void;\n  onCountdownEnd?: (announcement: Announcement) => void;\n  /** Accessible name of the region. */\n  label?: string;\n  className?: string;\n}\n\nconst storageKey = (id: string) => `arc-announcement:${id}`;\n/** Forgets a remembered dismissal so the bar with this id shows again. */\nexport function clearAnnouncementDismissal(id: string) {\n  try { window.localStorage.removeItem(storageKey(id)); } catch { /* Storage can be blocked. */ }\n}\n\ntype Bezier = [number, number, number, number];\nconst enter = [...motionTokens.ease.enter] as Bezier;\nconst standard = [...motionTokens.ease.standard] as Bezier;\nconst physical = (visualDuration: number, bounce: number): Transition => {\n  const root = 2 * Math.PI / (visualDuration * 1.2);\n  return { type: \"spring\", stiffness: root * root, damping: 2 * (1 - bounce) * root, mass: 1 };\n};\nconst RISE = physical(.46, 0), HEIGHT = physical(.42, 0), COLLAPSE = physical(.44, 0);\n\n/** Next rises from below and leaves upward; previous runs the other way. */\nconst faceVariants: Variants = {\n  hidden: (direction: number) => ({ opacity: 0, y: `${direction * 70}%`, filter: `blur(${motionTokens.blur.soft}px)` }),\n  shown: { opacity: 1, y: \"0%\", filter: \"blur(0px)\", transition: { y: RISE, opacity: { duration: motionTokens.duration.standard, ease: enter }, filter: { duration: motionTokens.duration.standard, ease: enter } } },\n  gone: (direction: number) => ({ opacity: 0, y: `${direction * -60}%`, filter: `blur(${motionTokens.blur.soft}px)`, transition: { y: RISE, opacity: { duration: motionTokens.duration.fast, ease: standard }, filter: { duration: motionTokens.duration.fast, ease: standard } } }),\n};\nconst fadeVariants: Variants = { hidden: { opacity: 0 }, shown: { opacity: 1, transition: { duration: motionTokens.duration.standard, ease: standard } }, gone: { opacity: 0, transition: { duration: motionTokens.duration.instant, ease: standard } } };\n\nfunction parts(ms: number) {\n  const total = Math.max(0, Math.floor(ms / 1000));\n  return { d: Math.floor(total / 86400), h: Math.floor(total % 86400 / 3600), m: Math.floor(total % 3600 / 60), s: total % 60 };\n}\nconst pad = (value: number) => String(value).padStart(2, \"0\");\n\n/**\n * One character cell. A changed digit drops in from above while the old one falls away, like a counter winding down.\n * The invisible sizer keeps the cell in the text flow, so it shares the baseline of the words around it.\n */\nfunction Digit({ char, reduced }: { char: string; reduced: boolean }) {\n  return <span className={styles.digit}>\n    <span className={styles.digitSizer} aria-hidden=\"true\">0</span>\n    <AnimatePresence initial={false}>\n      <motion.span key={char} className={styles.digitFace}\n        initial={reduced ? { opacity: 0 } : { y: \"-70%\", opacity: 0 }} animate={{ y: \"0%\", opacity: 1 }} exit={reduced ? { opacity: 0 } : { y: \"70%\", opacity: 0 }}\n        transition={reduced ? { duration: motionTokens.duration.instant } : { y: physical(.32, 0), opacity: { duration: motionTokens.duration.fast, ease: standard } }}>{char}</motion.span>\n    </AnimatePresence>\n  </span>;\n}\n\nfunction Countdown({ to, label, reduced, onEnd }: { to: Date | string | number; label?: string; reduced: boolean; onEnd?: () => void }) {\n  const target = new Date(to).getTime();\n  const [now, setNow] = useState<number | null>(null);\n  const ended = useRef(false);\n  const endRef = useRef(onEnd);\n  useEffect(() => { endRef.current = onEnd; }, [onEnd]);\n  useEffect(() => {\n    let timer = 0;\n    const tick = () => {\n      const current = Date.now();\n      setNow(current);\n      if (current >= target) {\n        if (!ended.current) { ended.current = true; endRef.current?.(); }\n        return;\n      }\n      // Wake just after each whole second so digits change on the beat.\n      timer = window.setTimeout(tick, 1000 - (current % 1000) + 8);\n    };\n    timer = window.setTimeout(tick, 0);\n    return () => window.clearTimeout(timer);\n  }, [target]);\n  const { d, h, m, s } = parts(now === null ? 0 : target - now);\n  const pending = now === null;\n  const spoken = pending ? \"\" : `${d ? `${d} days ` : \"\"}${h} hours ${m} minutes`;\n  const digits = (value: string, key: string) => value.split(\"\").map((char, index) => <Digit key={`${key}${value.length - index}`} char={pending ? \"0\" : char} reduced={reduced} />);\n  return <span className={styles.countdown}>\n    {label ? <span className={styles.countdownLabel}>{label}</span> : null}\n    <span role=\"timer\" aria-live=\"off\" aria-label={spoken} className={styles.time} data-pending={pending || undefined}>\n      {d ? <><span className={styles.unit}>{digits(String(d), \"d\")}<span className={styles.unitMark} aria-hidden=\"true\">d</span></span><span className={styles.gap} aria-hidden=\"true\" /></> : null}\n      {digits(pad(h), \"h\")}<span className={styles.sep} aria-hidden=\"true\">:</span>{digits(pad(m), \"m\")}<span className={styles.sep} aria-hidden=\"true\">:</span>{digits(pad(s), \"s\")}\n    </span>\n  </span>;\n}\n\nfunction Face({ announcement, direction, reduced, position, total, onSize, onAction, onCountdownEnd }: { announcement: Announcement; direction: number; reduced: boolean; position: number; total: number; onSize: (height: number) => void; onAction?: (announcement: Announcement) => void; onCountdownEnd?: (announcement: Announcement) => void }) {\n  const ref = useRef<HTMLDivElement>(null);\n  const present = useIsPresent();\n  useLayoutEffect(() => {\n    const node = ref.current;\n    if (!node || !present) return;\n    const report = () => onSize(node.offsetHeight);\n    report();\n    if (typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(report);\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [onSize, present]);\n  const { action, countdown } = announcement;\n  const cta = action ? action.href\n    ? <a className={styles.cta} href={action.href} onClick={() => { action.onClick?.(); onAction?.(announcement); }}>{action.label}<ArrowRight size={14} strokeWidth={1.75} aria-hidden=\"true\" /></a>\n    : <button type=\"button\" className={styles.cta} onClick={() => { action.onClick?.(); onAction?.(announcement); }}>{action.label}<ArrowRight size={14} strokeWidth={1.75} aria-hidden=\"true\" /></button> : null;\n  return <motion.div ref={ref} className={styles.face} custom={direction} variants={reduced ? fadeVariants : faceVariants} initial=\"hidden\" animate=\"shown\" exit=\"gone\"\n    role={total > 1 ? \"group\" : undefined} aria-roledescription={total > 1 ? \"slide\" : undefined} aria-label={total > 1 ? `${position} of ${total}` : undefined} inert={!present || undefined}>\n    <p className={styles.message}>\n      <span className={styles.text}>{announcement.message}</span>\n      {countdown ? <Countdown to={countdown.to} label={countdown.label} reduced={reduced} onEnd={() => onCountdownEnd?.(announcement)} /> : null}\n      {cta}\n    </p>\n  </motion.div>;\n}\n\nconst RING = 2 * Math.PI * 8;\n\nexport const AnnouncementBar = forwardRef<HTMLElement, AnnouncementBarProps>(function AnnouncementBar({\n  messages, id, open: openProp, defaultOpen = true, onOpenChange, index: indexProp, defaultIndex = 0, onIndexChange,\n  interval = 6000, autoPlay = true, controls = false, dismissible = true, tone = \"neutral\", onAction, onCountdownEnd, label = \"Announcements\", className,\n}, ref) {\n  const reduced = !!useReducedMotion();\n  const uid = useId();\n  const total = messages.length;\n\n  /* Open state: controlled, or internal and hidden at once when this id was dismissed before. */\n  const [openInternal, setOpenInternal] = useState(defaultOpen);\n  const [remembered, setRemembered] = useState(false);\n  useLayoutEffect(() => {\n    if (!id || openProp !== undefined) return;\n    let stored = false;\n    try { stored = window.localStorage.getItem(storageKey(id)) === \"dismissed\"; } catch { /* Storage can be blocked. */ }\n    // Storage is only readable on the client; hide before paint so a dismissed bar never flashes.\n    // eslint-disable-next-line react-hooks/set-state-in-effect\n    if (stored) setRemembered(true);\n  }, [id, openProp]);\n  const open = openProp ?? (openInternal && !remembered);\n\n  const dismiss = () => {\n    if (id) { try { window.localStorage.setItem(storageKey(id), \"dismissed\"); } catch { /* Storage can be blocked. */ } }\n    if (openProp === undefined) setOpenInternal(false);\n    onOpenChange?.(false);\n  };\n\n  /* Rotation. */\n  const [indexInternal, setIndexInternal] = useState(defaultIndex);\n  const current = total ? ((indexProp ?? indexInternal) % total + total) % total : 0;\n  const [direction, setDirection] = useState(1);\n  const go = useCallback((step: number) => {\n    if (total < 2) return;\n    const next = ((current + step) % total + total) % total;\n    setDirection(step > 0 ? 1 : -1);\n    if (indexProp === undefined) setIndexInternal(next);\n    onIndexChange?.(next);\n  }, [current, indexProp, onIndexChange, total]);\n\n  const [userPaused, setUserPaused] = useState(false);\n  const [hovered, setHovered] = useState(false);\n  const [focused, setFocused] = useState(false);\n  const [hidden, setHidden] = useState(false);\n  useEffect(() => {\n    const read = () => setHidden(document.visibilityState === \"hidden\");\n    document.addEventListener(\"visibilitychange\", read);\n    return () => document.removeEventListener(\"visibilitychange\", read);\n  }, []);\n  const rotating = autoPlay && !reduced && total > 1;\n  const running = rotating && open && !userPaused && !hovered && !focused && !hidden;\n\n  /* The ring is the clock: its progress runs to one, then advances. Pausing stops it where it is; resuming finishes the rest. */\n  const progress = useMotionValue(0);\n  const dash = useTransform(progress, value => RING * (1 - value));\n  const goRef = useRef(go);\n  useEffect(() => { goRef.current = go; }, [go]);\n  useEffect(() => { progress.jump(0); }, [current, progress]);\n  useEffect(() => {\n    if (!running) return;\n    let controls: AnimationPlaybackControls | null = animate(progress, 1, {\n      duration: interval / 1000 * (1 - progress.get()), ease: \"linear\",\n      onComplete: () => { controls = null; goRef.current(1); },\n    });\n    return () => controls?.stop();\n  }, [running, current, interval, progress]);\n\n  /* The viewport springs to the height of the current message, which can wrap on narrow screens. */\n  const height = useMotionValue<number | \"auto\">(\"auto\");\n  const measured = useRef(0);\n  const onSize = useCallback((next: number) => {\n    if (Math.abs(next - measured.current) < .5) return;\n    const first = measured.current === 0;\n    measured.current = next;\n    if (first || reduced) height.jump(next);\n    else animate(height, next, HEIGHT);\n  }, [height, reduced]);\n\n  const announcement = messages[current];\n  if (!announcement) return null;\n  const navigable = controls && total > 1;\n  const controlCount = (navigable ? 2 + (rotating ? 1 : 0) : 0) + (dismissible ? 1 : 0);\n\n  return <AnimatePresence initial={false}>\n    {open ? <motion.section key=\"bar\" ref={ref} className={[styles.collapse, className].filter(Boolean).join(\" \")} aria-label={label}\n      aria-roledescription={total > 1 ? \"carousel\" : undefined}\n      initial={reduced ? { opacity: 0 } : { height: 0, opacity: 0 }} animate={{ height: \"auto\", opacity: 1 }}\n      exit={reduced ? { opacity: 0, transition: { duration: motionTokens.duration.fast } } : { height: 0, opacity: 0, transition: { height: COLLAPSE, opacity: { duration: motionTokens.duration.exit, ease: standard } } }}\n      transition={reduced ? { duration: motionTokens.duration.fast } : { height: COLLAPSE, opacity: { duration: motionTokens.duration.standard, ease: enter } }}\n      onPointerEnter={event => { if (event.pointerType === \"mouse\") setHovered(true); }} onPointerLeave={() => setHovered(false)}\n      onFocus={() => setFocused(true)} onBlur={event => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setFocused(false); }}>\n      <div className={styles.bar} data-tone={tone} style={{ \"--controls\": controlCount } as CSSProperties}>\n        <div className={styles.side} aria-hidden=\"true\" />\n        <motion.div className={styles.viewport} style={{ height }} id={`${uid}-slides`} aria-live={running ? \"off\" : \"polite\"} aria-atomic=\"false\">\n          <AnimatePresence initial={false} custom={direction}>\n            <Face key={announcement.id} announcement={announcement} direction={direction} reduced={reduced} position={current + 1} total={total}\n              onSize={onSize} onAction={onAction} onCountdownEnd={onCountdownEnd} />\n          </AnimatePresence>\n        </motion.div>\n        <div className={styles.controls}>\n          {navigable ? <>\n            <button type=\"button\" className={styles.icon} aria-label=\"Previous announcement\" aria-controls={`${uid}-slides`} onClick={() => go(-1)}><ChevronUp size={16} strokeWidth={1.75} aria-hidden=\"true\" /></button>\n            {rotating ? <button type=\"button\" className={styles.ring} aria-label={userPaused ? \"Resume announcements\" : \"Pause announcements\"} aria-pressed={userPaused} onClick={() => setUserPaused(paused => !paused)}>\n              <svg viewBox=\"0 0 20 20\" aria-hidden=\"true\">\n                <circle cx=\"10\" cy=\"10\" r=\"8\" className={styles.ringTrack} />\n                <motion.circle cx=\"10\" cy=\"10\" r=\"8\" className={styles.ringFill} strokeDasharray={RING} style={{ strokeDashoffset: dash }} />\n              </svg>\n              <span className={styles.ringIcon}>{userPaused ? <Play size={9} strokeWidth={2.4} aria-hidden=\"true\" /> : <Pause size={9} strokeWidth={2.4} aria-hidden=\"true\" />}</span>\n            </button> : null}\n            <button type=\"button\" className={styles.icon} aria-label=\"Next announcement\" aria-controls={`${uid}-slides`} onClick={() => go(1)}><ChevronDown size={16} strokeWidth={1.75} aria-hidden=\"true\" /></button>\n          </> : null}\n          {dismissible ? <button type=\"button\" className={styles.icon} aria-label=\"Dismiss\" onClick={dismiss}><X size={16} strokeWidth={1.75} aria-hidden=\"true\" /></button> : null}\n        </div>\n      </div>\n    </motion.section> : null}\n  </AnimatePresence>;\n});\n\nexport default AnnouncementBar;\n"
    },
    {
      "path": "registry/components/announcement-bar/announcement-bar.module.css",
      "type": "registry:component",
      "target": "@components/xzero/announcement-bar/announcement-bar.module.css",
      "content": "/* The outer section animates its height to zero on dismiss, so whatever sits below eases up with it. */\n.collapse { overflow: hidden; container-type: inline-size; font-family: var(--font-body); letter-spacing: var(--tracking-body); }\n\n/* The side column mirrors the controls, so the message stays centered on the page rather than in the leftover space. */\n.bar { --icon: 32px; --control-gap: 2px; --muted: var(--text-secondary); display: grid; grid-template-columns: calc(var(--controls, 0) * var(--icon) + max(var(--controls, 0) - 1, 0) * var(--control-gap)) minmax(0, 1fr) auto; align-items: center; gap: var(--space-3); padding: 6px 8px 6px 16px; border-bottom: 1px solid var(--border); background: var(--surface-muted); color: var(--foreground); }\n.bar[data-tone=\"inverted\"] { --muted: color-mix(in oklab, var(--background) 66%, transparent); border-bottom-color: transparent; background: var(--foreground); color: var(--background); }\n\n.side { min-width: 0; }\n.viewport { position: relative; min-height: 32px; overflow: hidden; }\n\n/* Faces overlap in place; the entering one reports its height and the viewport springs to it. */\n.face { position: absolute; top: 0; right: 0; left: 0; display: flex; min-height: 32px; align-items: center; justify-content: center; }\n.message { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: center; gap: 2px 12px; margin: 0; padding: 6px 0; font-size: var(--text-sm); line-height: var(--leading-body); text-align: center; text-wrap: balance; }\n.text { min-width: 0; }\n\n/* The countdown is one run of text: every cell shares the line box, so digits, colons, and the day mark sit on the message baseline. */\n.countdown { display: inline-flex; align-items: baseline; gap: 6px; color: var(--muted); white-space: nowrap; }\n.countdownLabel { color: inherit; }\n.time { display: inline-flex; align-items: baseline; color: var(--foreground); font-variant-numeric: tabular-nums; font-weight: 500; line-height: inherit; }\n.time[data-pending] { opacity: 0; }\n.bar[data-tone=\"inverted\"] .time { color: var(--background); }\n.digit { position: relative; display: inline-block; overflow: hidden; overflow: clip; text-align: center; }\n.digitSizer { visibility: hidden; }\n.digitFace { position: absolute; inset: 0; display: block; }\n.unit { display: inline-flex; align-items: baseline; }\n.unitMark { margin-left: 1px; color: var(--muted); font-weight: 400; }\n/* \"case\" lifts the colon to the middle of the figures, which it loses once the digits are split into cells. */\n.sep { display: inline-block; padding-inline: 1px; color: var(--muted); font-feature-settings: \"case\"; font-weight: 400; }\n.gap { display: inline-block; width: .4em; }\n\n.cta { display: inline-flex; align-items: center; gap: 4px; padding: 0; border: 0; background: none; color: inherit; cursor: pointer; font: inherit; font-weight: 500; text-decoration: none; white-space: nowrap; -webkit-tap-highlight-color: transparent; }\n.cta svg { transition: transform var(--duration-fast) var(--ease-standard); }\n\n.controls { display: flex; align-items: center; gap: var(--control-gap); }\n.icon, .ring { position: relative; display: grid; width: var(--icon); height: var(--icon); flex: none; place-items: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: var(--muted); cursor: pointer; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); }\n.icon:active, .ring:active { background: color-mix(in oklab, currentColor 12%, transparent); color: inherit; }\n.ring svg { width: 20px; height: 20px; overflow: visible; transform: rotate(-90deg); }\n.ringTrack { fill: none; stroke: currentColor; stroke-opacity: .22; stroke-width: 1.75; }\n.ringFill { fill: none; stroke: currentColor; stroke-linecap: round; stroke-width: 1.75; }\n.ringIcon { position: absolute; inset: 0; display: grid; place-items: center; }\n.ringIcon svg { width: 9px; height: 9px; transform: none; fill: currentColor; }\n.bar[data-tone=\"inverted\"] .icon, .bar[data-tone=\"inverted\"] .ring { color: var(--muted); }\n\n@media (hover: hover) and (pointer: fine) {\n  .icon:hover, .ring:hover { background: color-mix(in oklab, currentColor 10%, transparent); }\n  .bar .icon:hover, .bar .ring:hover { color: var(--foreground); }\n  .bar[data-tone=\"inverted\"] .icon:hover, .bar[data-tone=\"inverted\"] .ring:hover { color: var(--background); }\n  .cta:hover svg { transform: translateX(2px); }\n}\n\n/* Narrow bars drop the mirror column and read from the start edge. */\n@container (max-width: 560px) {\n  .bar { grid-template-columns: 0 minmax(0, 1fr) auto; gap: 0 var(--space-2); padding-left: 14px; }\n  .face { justify-content: flex-start; }\n  .message { justify-content: flex-start; column-gap: 10px; text-align: left; }\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .icon, .ring, .cta svg { transition: none; }\n}\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/announcement-bar",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "feedback",
      "new"
    ],
    "docs": "https://ui.xzero.co.uk/components/announcement-bar",
    "markdown": "https://ui.xzero.co.uk/components/announcement-bar/markdown"
  }
}