{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "banner",
  "type": "registry:block",
  "title": "Banner",
  "description": "An announcement bar at the top of the page that people can dismiss. It is a named landmark, and it is announced to screen readers only if you turn that on.",
  "categories": [
    "section"
  ],
  "dependencies": [
    "lucide-react@^1.47.0"
  ],
  "registryDependencies": [
    "https://facadeui.dev/r/button.json",
    "https://facadeui.dev/r/container.json",
    "https://facadeui.dev/r/types.json",
    "https://facadeui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/sections/banner.tsx",
      "type": "registry:component",
      "target": "components/sections/banner.tsx",
      "content": "\"use client\"\n\n/**\n * Banner — the announcement bar above the header.\n *\n * a11y: the things that make announcement bars annoying are all accessibility\n * problems in disguise.\n *\n *  - It is a named `<section>`, so it can be skipped by landmark navigation\n *    rather than being met again on every page.\n *  - Dismissal is a real `<button>` with an accessible name that says what it\n *    dismisses, not a bare \"×\".\n *  - `aria-live` is deliberately **absent**. A banner that is present on load is\n *    not an update, and announcing it would interrupt whatever the user was\n *    doing. Pass `announce` only when the banner appears in response to\n *    something, which is the case a live region is actually for.\n *  - Nothing is rendered before hydration decides whether it was dismissed, so\n *    a dismissed banner never flashes back on navigation.\n *\n * `storageKey` persists the dismissal in localStorage, read through\n * `useSyncExternalStore` rather than an effect — localStorage *is* an external\n * store, and the effect form schedules a cascading render on every load, which\n * is what `react-hooks/set-state-in-effect` exists to catch.\n *\n * The trade-off that comes with it: the server cannot know whether a visitor\n * dismissed the banner, so it renders, and a returning visitor who dismissed it\n * sees it for one frame. The alternative — render nothing until the stored value\n * is known — shifts the layout for *everyone else*, which is the larger group\n * and the worse outcome. If your banner is dismissed by most visitors, inline a\n * pre-paint script that sets `hidden` on the element, the same way a theme\n * script avoids a flash of the wrong colours.\n *\n * Storage can throw in private mode, so every access is guarded and the banner\n * simply shows.\n *\n * Dependencies: lucide-react, react, @/lib/types, @/lib/utils,\n * @/components/ui/button, @/components/ui/container.\n */\n\nimport { XIcon } from \"lucide-react\"\nimport {\n  useCallback,\n  useState,\n  useSyncExternalStore,\n  type ElementType,\n  type ReactNode,\n} from \"react\"\n\nimport type { LinkComponent } from \"@/lib/types\"\nimport { cn } from \"@/lib/utils\"\nimport { buttonVariants } from \"@/components/ui/button\"\nimport { Container } from \"@/components/ui/container\"\n\nexport interface BannerProps {\n  children: ReactNode\n  /** Names the landmark, e.g. \"Announcement\". */\n  label?: string\n  /** Optional call to action at the end of the message. */\n  action?: { label: string; href: string; external?: boolean }\n  link?: LinkComponent\n  /** Adds a dismiss button. */\n  dismissible?: boolean\n  /** Remembers the dismissal under this localStorage key. */\n  storageKey?: string\n  /** The accessible label of the dismiss button. */\n  dismissLabel?: string\n  variant?: \"primary\" | \"muted\" | \"card\"\n  onDismiss?: () => void\n  /**\n   * Announces the banner to screen readers when it appears. Use this only for banners\n   * shown in response to an action, never for one that is there when the page loads.\n   */\n  announce?: boolean\n  className?: string\n  id?: string\n}\n\nconst listeners = new Set<() => void>()\n\nfunction subscribe(onChange: () => void): () => void {\n  listeners.add(onChange)\n  window.addEventListener(\"storage\", onChange)\n  return () => {\n    listeners.delete(onChange)\n    window.removeEventListener(\"storage\", onChange)\n  }\n}\n\nfunction readDismissed(storageKey: string | undefined): boolean {\n  if (!storageKey) return false\n  try {\n    return localStorage.getItem(storageKey) === \"dismissed\"\n  } catch {\n    return false\n  }\n}\n\nconst surfaces = {\n  primary: \"bg-primary text-primary-foreground\",\n  muted: \"bg-muted text-foreground\",\n  card: \"bg-card text-card-foreground border-b\",\n} as const\n\nexport function Banner({\n  children,\n  label = \"Announcement\",\n  action,\n  link,\n  dismissible = false,\n  storageKey,\n  dismissLabel,\n  variant = \"primary\",\n  onDismiss,\n  announce = false,\n  className,\n  id,\n}: BannerProps) {\n  const Link = (link ?? \"a\") as ElementType\n\n  const storedDismissal = useSyncExternalStore(\n    subscribe,\n    () => readDismissed(storageKey),\n    // The server has no storage; not-dismissed is the right assumption.\n    () => false,\n  )\n  // Without a `storageKey` there is nothing to read back, so the dismissal has\n  // to live in component state for the rest of the session.\n  const [sessionDismissed, setSessionDismissed] = useState(false)\n  const dismissed = storedDismissal || sessionDismissed\n\n  const dismiss = useCallback(() => {\n    setSessionDismissed(true)\n    if (storageKey) {\n      try {\n        localStorage.setItem(storageKey, \"dismissed\")\n      } catch {\n        // Dismissal simply does not persist.\n      }\n      for (const listener of [...listeners]) listener()\n    }\n    onDismiss?.()\n  }, [storageKey, onDismiss])\n\n  if (dismissed) return null\n\n  return (\n    <section\n      id={id}\n      aria-label={label}\n      {...(announce ? { role: \"status\", \"aria-live\": \"polite\" as const } : {})}\n      className={cn(\"relative w-full text-sm\", surfaces[variant], className)}\n    >\n      <Container className=\"flex min-h-12 flex-wrap items-center justify-center gap-x-4 gap-y-2 py-2.5 pr-12 text-center\">\n        <p className=\"text-pretty\">{children}</p>\n\n        {action ? (\n          <Link\n            href={action.href}\n            {...(action.external ? { target: \"_blank\", rel: \"noopener noreferrer\" } : {})}\n            className=\"focus-visible:ring-ring rounded-sm font-medium underline underline-offset-4 focus-visible:outline-none focus-visible:ring-2\"\n          >\n            {action.label}\n            {action.external ? (\n              <span className=\"sr-only\"> (opens in a new tab)</span>\n            ) : null}\n          </Link>\n        ) : null}\n      </Container>\n\n      {dismissible ? (\n        <button\n          type=\"button\"\n          onClick={dismiss}\n          className={cn(\n            buttonVariants({ variant: \"ghost\", size: \"icon\" }),\n            \"absolute right-2 top-1/2 size-9 -translate-y-1/2\",\n            // Ghost's `--foreground` icon can vanish on the primary surface.\n            variant === \"primary\" &&\n              \"text-primary-foreground hover:bg-primary-foreground/15 hover:text-primary-foreground\",\n          )}\n        >\n          <XIcon aria-hidden focusable=\"false\" className=\"size-4\" />\n          <span className=\"sr-only\">\n            {dismissLabel ?? `Dismiss ${label.toLowerCase()}`}\n          </span>\n        </button>\n      ) : null}\n    </section>\n  )\n}\n"
    }
  ],
  "docs": "Import styles/facade-tokens.css in your global stylesheet, after @import \"tailwindcss\". Set headingLevel to fit the page outline (sections default to h2). Pass next/link and next/image through the link and image props. Docs: https://facadeui.dev/components/banner"
}
