Skip to content
Facade UI
blocksection

Banner

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.

Open in v0 (opens in a new tab)

Preview

Open full width (opens in a new tab)
Preview width

Installation

Package manager
pnpm dlx shadcn@latest add https://facadeui.dev/r/banner.json

This component needs lucide-react@^1.47.0, button, container, types, utils. The CLI installs them for you.

Usage

This is the code for the preview above. To install it as a file, run npx shadcn@latest add @facade/banner-demo.

demos/banner.tsx
import { Banner } from "@registry/sections/banner"

export function Demo() {
  return (
    <div className="-m-6 flex flex-col gap-6 sm:-m-10">
      <Banner
        label="Announcement"
        dismissible
        action={{ label: "Read the notes", href: "#release" }}
      >
        Facade UI v0.1 is out.
      </Banner>
      <Banner variant="muted" label="Maintenance notice" dismissible>
        Scheduled maintenance on Sunday, 02:00–04:00 UTC.
      </Banner>
      <p className="text-muted-foreground px-6 pb-6 text-sm sm:px-10">
        Dismissal is session-only here. Pass <code>storageKey</code> to remember it.
      </p>
    </div>
  )
}

Source

These are the files the CLI copies into your project.

components/sections/banner.tsx
"use client"

/**
 * Banner — the announcement bar above the header.
 *
 * a11y: the things that make announcement bars annoying are all accessibility
 * problems in disguise.
 *
 *  - It is a named `<section>`, so it can be skipped by landmark navigation
 *    rather than being met again on every page.
 *  - Dismissal is a real `<button>` with an accessible name that says what it
 *    dismisses, not a bare "×".
 *  - `aria-live` is deliberately **absent**. A banner that is present on load is
 *    not an update, and announcing it would interrupt whatever the user was
 *    doing. Pass `announce` only when the banner appears in response to
 *    something, which is the case a live region is actually for.
 *  - Nothing is rendered before hydration decides whether it was dismissed, so
 *    a dismissed banner never flashes back on navigation.
 *
 * `storageKey` persists the dismissal in localStorage, read through
 * `useSyncExternalStore` rather than an effect — localStorage *is* an external
 * store, and the effect form schedules a cascading render on every load, which
 * is what `react-hooks/set-state-in-effect` exists to catch.
 *
 * The trade-off that comes with it: the server cannot know whether a visitor
 * dismissed the banner, so it renders, and a returning visitor who dismissed it
 * sees it for one frame. The alternative — render nothing until the stored value
 * is known — shifts the layout for *everyone else*, which is the larger group
 * and the worse outcome. If your banner is dismissed by most visitors, inline a
 * pre-paint script that sets `hidden` on the element, the same way a theme
 * script avoids a flash of the wrong colours.
 *
 * Storage can throw in private mode, so every access is guarded and the banner
 * simply shows.
 *
 * Dependencies: lucide-react, react, @/lib/types, @/lib/utils,
 * @/components/ui/button, @/components/ui/container.
 */

import { XIcon } from "lucide-react"
import {
  useCallback,
  useState,
  useSyncExternalStore,
  type ElementType,
  type ReactNode,
} from "react"

import type { LinkComponent } from "@/lib/types"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
import { Container } from "@/components/ui/container"

export interface BannerProps {
  children: ReactNode
  /** Names the landmark, e.g. "Announcement". */
  label?: string
  /** Optional call to action at the end of the message. */
  action?: { label: string; href: string; external?: boolean }
  link?: LinkComponent
  /** Adds a dismiss button. */
  dismissible?: boolean
  /** Remembers the dismissal under this localStorage key. */
  storageKey?: string
  /** The accessible label of the dismiss button. */
  dismissLabel?: string
  variant?: "primary" | "muted" | "card"
  onDismiss?: () => void
  /**
   * Announces the banner to screen readers when it appears. Use this only for banners
   * shown in response to an action, never for one that is there when the page loads.
   */
  announce?: boolean
  className?: string
  id?: string
}

const listeners = new Set<() => void>()

function subscribe(onChange: () => void): () => void {
  listeners.add(onChange)
  window.addEventListener("storage", onChange)
  return () => {
    listeners.delete(onChange)
    window.removeEventListener("storage", onChange)
  }
}

function readDismissed(storageKey: string | undefined): boolean {
  if (!storageKey) return false
  try {
    return localStorage.getItem(storageKey) === "dismissed"
  } catch {
    return false
  }
}

const surfaces = {
  primary: "bg-primary text-primary-foreground",
  muted: "bg-muted text-foreground",
  card: "bg-card text-card-foreground border-b",
} as const

export function Banner({
  children,
  label = "Announcement",
  action,
  link,
  dismissible = false,
  storageKey,
  dismissLabel,
  variant = "primary",
  onDismiss,
  announce = false,
  className,
  id,
}: BannerProps) {
  const Link = (link ?? "a") as ElementType

  const storedDismissal = useSyncExternalStore(
    subscribe,
    () => readDismissed(storageKey),
    // The server has no storage; not-dismissed is the right assumption.
    () => false,
  )
  // Without a `storageKey` there is nothing to read back, so the dismissal has
  // to live in component state for the rest of the session.
  const [sessionDismissed, setSessionDismissed] = useState(false)
  const dismissed = storedDismissal || sessionDismissed

  const dismiss = useCallback(() => {
    setSessionDismissed(true)
    if (storageKey) {
      try {
        localStorage.setItem(storageKey, "dismissed")
      } catch {
        // Dismissal simply does not persist.
      }
      for (const listener of [...listeners]) listener()
    }
    onDismiss?.()
  }, [storageKey, onDismiss])

  if (dismissed) return null

  return (
    <section
      id={id}
      aria-label={label}
      {...(announce ? { role: "status", "aria-live": "polite" as const } : {})}
      className={cn("relative w-full text-sm", surfaces[variant], className)}
    >
      <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">
        <p className="text-pretty">{children}</p>

        {action ? (
          <Link
            href={action.href}
            {...(action.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
            className="focus-visible:ring-ring rounded-sm font-medium underline underline-offset-4 focus-visible:outline-none focus-visible:ring-2"
          >
            {action.label}
            {action.external ? (
              <span className="sr-only"> (opens in a new tab)</span>
            ) : null}
          </Link>
        ) : null}
      </Container>

      {dismissible ? (
        <button
          type="button"
          onClick={dismiss}
          className={cn(
            buttonVariants({ variant: "ghost", size: "icon" }),
            "absolute right-2 top-1/2 size-9 -translate-y-1/2",
            // Ghost's `--foreground` icon can vanish on the primary surface.
            variant === "primary" &&
              "text-primary-foreground hover:bg-primary-foreground/15 hover:text-primary-foreground",
          )}
        >
          <XIcon aria-hidden focusable="false" className="size-4" />
          <span className="sr-only">
            {dismissLabel ?? `Dismiss ${label.toLowerCase()}`}
          </span>
        </button>
      ) : null}
    </section>
  )
}

Props

BannerProps

Props for BannerProps
PropTypeDefault
children*RequiredReactNode—
labelNames the landmark, e.g. "Announcement".string"Announcement"
actionOptional call to action at the end of the message.{ label: string; href: string; external?: boolean }—
linkLinkComponent—
dismissibleAdds a dismiss button.booleanfalse
storageKeyRemembers the dismissal under this localStorage key.string—
dismissLabelThe accessible label of the dismiss button.string—
variant"primary" | "muted" | "card""primary"
onDismiss() => void—
announceAnnounces the banner to screen readers when it appears. Use this only for banners shown in response to an action, never for one that is there when the page loads.booleanfalse
classNamestring—
idstring—