blocksection
Banner
A dismissible announcement bar. A named landmark, and deliberately not a live region unless you ask.
Preview
Installation
pnpm dlx shadcn@latest add https://facadeui.dev/r/banner.jsonPulls in lucide-react@^1.47.0, button, container, types, utils. The CLI installs them for you.
Usage
The exact source of the preview above.
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
What the CLI copies into your project, byte for byte.
"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
/** What the dismiss button says it is closing. */
dismissLabel?: string
variant?: "primary" | "muted" | "card"
onDismiss?: () => void
/**
* Announce the banner when it appears. Only for banners that show in response
* to an action — never for one that is present on load.
*/
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",
variant === "primary" &&
"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
| Prop | Type | Default |
|---|---|---|
children*Required | ReactNode | — |
labelNames the landmark, e.g. "Announcement". | string | "Announcement" |
actionOptional call to action at the end of the message. | { label: string; href: string; external?: boolean } | — |
link | LinkComponent | — |
dismissibleAdds a dismiss button. | boolean | false |
storageKeyRemembers the dismissal under this localStorage key. | string | — |
dismissLabelWhat the dismiss button says it is closing. | string | — |
variant | "primary" | "muted" | "card" | "primary" |
onDismiss | () => void | — |
announceAnnounce the banner when it appears. Only for banners that show in response
to an action — never for one that is present on load. | boolean | false |
className | string | — |
id | string | — |