Banner
Dismissible announcement bar for the top of a page: start and centered variants, a background slot for art, a link that becomes a full-bar tap target on phones, and a dismiss button that hands focus onward.
pnpm dlx shadcn add @01orbital/banner"use client"
import { createContext, use, useState, type ComponentProps } from "react"
import { ArrowRightIcon, XIcon } from "@phosphor-icons/react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
import { Button } from "@/components/ui/button"
const BannerContext = createContext<{
dismiss: (from: HTMLElement) => void
} | null>(null)
const TABBABLE =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
// The dismiss button leaves with the banner, so focus goes where Tab would.
function nextTabbable(banner: HTMLElement) {
return Array.from(document.querySelectorAll<HTMLElement>(TABBABLE)).find(
(el) =>
!banner.contains(el) &&
banner.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING &&
el.tabIndex >= 0 &&
!el.matches(":disabled") &&
!el.closest("[inert]") &&
(el.checkVisibility
? el.checkVisibility({ visibilityProperty: true })
: el.getClientRects().length > 0 &&
!["hidden", "collapse"].includes(getComputedStyle(el).visibility))
)
}
const bannerVariants = cva(
"group/banner relative isolate flex w-full flex-nowrap items-center gap-x-4 gap-y-2 overflow-hidden px-6 py-2 text-[13px]/5 lg:px-10",
{
variants: {
variant: {
start: "",
// The spacer mirrors the dismiss column so the message stays centered.
center:
"justify-center has-data-[slot=banner-dismiss]:before:min-w-0 has-data-[slot=banner-dismiss]:before:flex-1",
},
},
defaultVariants: { variant: "start" },
}
)
// Controlled: dismissing only calls onOpenChange(false); the parent sets open.
/**
* A thin announcement bar. The text steps from 13px to 14px
* and the message wraps on screens 40rem and wider (`sm:`), and below that the
* link label hides. The side padding grows at the `lg` screen width.
*/
function Banner({
className,
variant = "start",
open: openProp,
defaultOpen = true,
onOpenChange,
children,
...props
}: ComponentProps<"div"> &
VariantProps<typeof bannerVariants> & {
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
}) {
const [openState, setOpenState] = useState(defaultOpen)
const open = openProp ?? openState
if (!open) return null
const dismiss = (from: HTMLElement) => {
const banner = from.closest<HTMLElement>("[data-slot=banner]")
const next = banner?.contains(document.activeElement)
? nextTabbable(banner)
: undefined
setOpenState(false)
onOpenChange?.(false)
// Only once it is really gone: a controlled parent may keep it open.
if (banner && next)
requestAnimationFrame(() => {
if (!banner.isConnected) next.focus()
})
}
return (
<BannerContext value={{ dismiss }}>
<div
data-slot="banner"
data-variant={variant}
className={cn(bannerVariants({ variant }), className)}
{...props}
>
{children}
</div>
</BannerContext>
)
}
// Paints behind the message; put art or glow here, never under the text alone.
function BannerBackground({ className, ...props }: ComponentProps<"div">) {
return (
<div
aria-hidden="true"
data-slot="banner-background"
className={cn(
"pointer-events-none absolute inset-0 -z-10 overflow-hidden",
className
)}
{...props}
/>
)
}
function BannerContent({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="banner-content"
className={cn(
"flex min-w-0 flex-1 flex-row items-center gap-x-4 gap-y-2 group-data-[variant=center]/banner:flex-initial group-data-[variant=center]/banner:justify-center group-data-[variant=center]/banner:text-center max-sm:gap-x-2 sm:flex-wrap sm:text-sm/6",
className
)}
{...props}
/>
)
}
// On phones the label hides, the arrow stays and the whole bar becomes the
// link's tap target, so the banner holds to one line. The label is still the
// link's accessible name. labelOnPhone keeps the label visible, for a
// button-styled link that would otherwise shrink to a bare arrow.
function BannerLink({
className,
children,
icon = <ArrowRightIcon aria-hidden="true" className="size-3.5" />,
labelOnPhone = false,
...props
}: ComponentProps<"a"> & { icon?: React.ReactNode; labelOnPhone?: boolean }) {
return (
<a
data-slot="banner-link"
className={cn(
"inline-flex shrink-0 items-center gap-1 rounded-sm font-semibold underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current",
"max-sm:after:absolute max-sm:after:inset-0 max-sm:focus-visible:outline-none max-sm:focus-visible:after:outline-2 max-sm:focus-visible:after:-outline-offset-2 max-sm:focus-visible:after:outline-current",
className
)}
{...props}
>
<span className={labelOnPhone ? undefined : "max-sm:sr-only"}>
{children}
</span>
{icon}
</a>
)
}
function BannerDismiss({
className,
"aria-label": label = "Dismiss",
onClick,
children = <XIcon aria-hidden="true" />,
...props
}: Omit<ComponentProps<typeof Button>, "className"> & {
// A string class only: the stock Button drops a class callback.
className?: string
}) {
const ctx = use(BannerContext)
return (
<div
data-slot="banner-actions"
className="relative flex flex-1 items-center justify-end gap-2 group-data-[variant=start]/banner:flex-none"
>
<Button
data-slot="banner-dismiss"
variant="ghost"
size="icon-sm"
aria-label={label}
className={cn("-my-1 -mr-2 hover:bg-current/10", className)}
onClick={(event) => {
onClick?.(event)
if (!event.defaultPrevented) ctx?.dismiss(event.currentTarget)
}}
{...props}
>
{children}
</Button>
</div>
)
}
export { Banner, BannerBackground, BannerContent, BannerDismiss, BannerLink }
import {
Banner,
BannerContent,
BannerDismiss,
BannerLink,
} from "@/components/banner/banner"
// The copy: change text here, not in the markup.
const COPY = {
message: "Version 2 ships on Monday with eleven new blocks.",
link: "See what's new",
dismiss: "Dismiss announcement",
} as const
/** A dismissible Banner: one line of text, a link and the dismiss button. */
export default function BannerDemo() {
return (
<Banner className="bg-primary text-primary-foreground">
<BannerContent>
<p>{COPY.message}</p>
</BannerContent>
<BannerLink href="#">{COPY.link}</BannerLink>
<BannerDismiss aria-label={COPY.dismiss} />
</Banner>
)
}
Usage
import {
Banner,
BannerContent,
BannerDismiss,
BannerLink,
} from "@/components/banner/banner"
// The copy: change text here, not in the markup.
const COPY = {
message: "Version 2 ships on Monday with eleven new blocks.",
link: "See what's new",
dismiss: "Dismiss announcement",
} as const
/** A dismissible Banner: one line of text, a link and the dismiss button. */
export default function BannerDemo() {
return (
<Banner className="bg-primary text-primary-foreground">
<BannerContent>
<p>{COPY.message}</p>
</BannerContent>
<BannerLink href="#">{COPY.link}</BannerLink>
<BannerDismiss aria-label={COPY.dismiss} />
</Banner>
)
}Examples
New
banner-museum-notice
New
banner-exhibition
New
banner-bookshop
New
banner-release-bulletin
banner-blue-glow-line
banner-neoconstruct-event
banner-new-feature-badge
banner-archived-workspace
banner-gradient-live-event
banner-raised-referral
banner-shaded-url-change
banner-corner-sheen
banner-dark-paper-texture
banner-dark-glow-seam
banner-muted-release
banner-maintenance-notice
banner-primary-color
banner-billing-warning
Anatomy
Banner is built from 5 parts. In the example they nest like this:
Banner
├─ BannerContent
├─ BannerLink
└─ BannerDismissBanner: A thin announcement bar. The text steps from 13px to 14px and the message wraps on screens 40rem and wider (sm:), and below that the link label hides. The side padding grows at thelgscreen width.BannerContentBannerLinkBannerDismissBannerBackground
API reference
Banner
A thin announcement bar. The text steps from 13px to 14px and the message wraps on screens 40rem and wider (sm:), and below that the link label hides. The side padding grows at the lg screen width.
Prop
Type
It also accepts the HTML attributes of the element it renders.
BannerContent
It accepts the HTML attributes of the element it renders.
BannerLink
Prop
Type
It also accepts the HTML attributes of the element it renders.
BannerDismiss
Prop
Type
It also accepts the props of the Base UI part it wraps, and the HTML attributes of the element it renders.
BannerBackground
It accepts the HTML attributes of the element it renders.
Dependencies
npm: @phosphor-icons/react, class-variance-authority, cn. The CLI installs them for you.
Registry pieces it installs with it: