Banner
A page-level notice in one of five tones, with an action slot and its own dismiss.
A client component — dismissing is internal state, so the banner removes itself and then calls onDismiss. Every tone ships a shaped icon (info, check, triangle, cross) so the meaning survives without color. danger announces as an alert; every other tone waits politely. The HTML title attribute is replaced by the title prop.
Install
$
npx shadcn@latest add @vibra/bannerNeeds the @vibra registry in your components.json — set it up once.
Examples
import { Banner } from "@/components/ui/banner"
import { Button } from "@/components/ui/button"
export default function BannerDemo() {
return (
<Banner
className="w-full max-w-md"
variant="warning"
title="Warehouse sync is paused"
action={
<Button variant="outline" size="sm">
Reconnect
</Button>
}
dismissible
>
Dashboards are showing data from 14:20 UTC. Reconnect the warehouse to resume the hourly sync.
</Banner>
)
}Tones and placement
Every tone, an icon turned off, and the top-pinned strip.
import { Banner } from "@/components/ui/banner"
import { Button } from "@/components/ui/button"
export default function BannerVariants() {
return (
<div className="flex w-full max-w-md flex-col gap-3">
<Banner variant="info" title="New in 2.4">
Saved filters now travel with a shared dashboard link.
</Banner>
<Banner variant="success" title="Backfill complete">
4.2M rows rewritten. Revenue for Q2 is accurate again.
</Banner>
<Banner variant="warning" title="Approaching the row limit">
This workspace is at 92% of its 50M row plan.
</Banner>
<Banner
variant="danger"
title="Ingest failed"
action={
<Button variant="outline" size="sm">
View log
</Button>
}
>
The 14:00 run stopped after 12 seconds. No partial data was written.
</Banner>
<Banner variant="default" title="Read-only workspace" icon={null}>
Ask an owner for edit access to change these panels.
</Banner>
<div className="overflow-hidden rounded-lg border border-border">
<Banner variant="info" position="top" dismissible>
Pinned to the top of a page: square corners, one hairline underneath.
</Banner>
<div className="p-4 text-sm text-muted-foreground">Page content sits below the strip.</div>
</div>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | "default" | "neutral" | "success" | "warning" | "danger" | "info" | "default" | Sets the background, the border, and the default icon; neutral is an alias of default. |
| title | React.ReactNode | — | The headline of the notice. |
| children | React.ReactNode | — | The body of the notice, below the title. |
| icon | React.ReactNode | null | the tone's icon | Replaces the tone's default icon; pass null for no icon at all. |
| action | React.ReactNode | — | A button or link at the end of the row. |
| dismissible | boolean | false | Adds a close button that removes the banner. |
| onDismiss | () => void | — | Called after the banner removes itself, for persisting the dismissal. |
| position | "inline" | "top" | "inline" | top squares the corners and keeps only the bottom hairline, for a page-wide strip. |
Dependencies
Source
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { CircleCheckIcon, CircleXIcon, InfoIcon, TriangleAlertIcon, XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
const bannerVariants = cva("flex w-full items-start gap-3 border text-sm text-foreground", {
variants: {
variant: {
default: "border-border bg-muted",
// Same as default; both spellings exist so a tone prop can be passed through verbatim.
neutral: "border-border bg-muted",
success: "border-success/25 bg-success-muted",
warning: "border-warning/30 bg-warning-muted",
danger: "border-danger/25 bg-danger-muted",
info: "border-info/25 bg-info-muted",
},
position: {
inline: "rounded-lg px-3.5 py-3",
// Pinned above the content: one hairline underneath, no corners of its own.
top: "rounded-none border-x-0 border-t-0 px-4 py-3",
},
},
defaultVariants: { variant: "default", position: "inline" },
})
export type BannerVariant = NonNullable<VariantProps<typeof bannerVariants>["variant"]>
const BANNER_ICON_TONES: Record<BannerVariant, string> = {
default: "text-muted-foreground",
neutral: "text-muted-foreground",
success: "text-success",
warning: "text-warning",
danger: "text-danger",
info: "text-info",
}
// Shape carries the tone as well as color does: a check never means the same as a cross.
const BANNER_ICONS: Record<BannerVariant, React.ComponentType<React.ComponentProps<"svg">>> = {
default: InfoIcon,
neutral: InfoIcon,
success: CircleCheckIcon,
warning: TriangleAlertIcon,
danger: CircleXIcon,
info: InfoIcon,
}
// `title` is content here, not the HTML tooltip attribute, so it replaces it.
export type BannerProps = Omit<React.ComponentProps<"div">, "title"> & {
variant?: BannerVariant
title?: React.ReactNode
/** Replaces the tone's default icon; pass null for no icon at all. */
icon?: React.ReactNode | null
/** A button or link at the end of the row. */
action?: React.ReactNode
dismissible?: boolean
onDismiss?: () => void
/** top squares the corners and keeps only the bottom hairline, for a page-wide strip. */
position?: NonNullable<VariantProps<typeof bannerVariants>["position"]>
}
function Banner({
className,
variant = "default",
title,
icon,
action,
dismissible = false,
onDismiss,
position = "inline",
children,
...props
}: BannerProps) {
const [dismissed, setDismissed] = React.useState(false)
if (dismissed) return null
const DefaultIcon = BANNER_ICONS[variant]
const resolvedIcon = icon === undefined ? <DefaultIcon /> : icon
return (
<div
data-slot="banner"
data-variant={variant}
data-position={position}
data-dismissible={dismissible || undefined}
// A failure interrupts; everything else waits for a pause in the reading.
role={variant === "danger" ? "alert" : "status"}
className={cn(bannerVariants({ variant, position }), className)}
{...props}
>
{resolvedIcon ? (
<span
data-slot="banner-icon"
aria-hidden="true"
className={cn(
"mt-px shrink-0 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
BANNER_ICON_TONES[variant]
)}
>
{resolvedIcon}
</span>
) : null}
<div data-slot="banner-content" className="flex min-w-0 flex-1 flex-col gap-0.5">
{title ? (
<div data-slot="banner-title" className="font-medium">
{title}
</div>
) : null}
{children ? (
<div data-slot="banner-description" className="text-pretty">
{children}
</div>
) : null}
</div>
{action ? (
<div data-slot="banner-action" className="-my-0.5 shrink-0">
{action}
</div>
) : null}
{dismissible ? (
<Button
data-slot="banner-dismiss"
type="button"
variant="ghost"
size="icon-sm"
aria-label="Dismiss"
onClick={() => {
setDismissed(true)
onDismiss?.()
}}
className="-my-0.5 -me-1.5 shrink-0 text-muted-foreground hover:bg-foreground/5 hover:text-foreground"
>
<XIcon aria-hidden="true" />
</Button>
) : null}
</div>
)
}
export { Banner, bannerVariants }