Skip to contentVibraUI
Feedback & status

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/banner

Needs the @vibra registry in your components.json — set it up once.

Examples

Tones and placement

Every tone, an icon turned off, and the top-pinned strip.

Props

PropTypeDefaultDescription
variant"default" | "neutral" | "success" | "warning" | "danger" | "info""default"Sets the background, the border, and the default icon; neutral is an alias of default.
titleReact.ReactNode—The headline of the notice.
childrenReact.ReactNode—The body of the notice, below the title.
iconReact.ReactNode | nullthe tone's iconReplaces the tone's default icon; pass null for no icon at all.
actionReact.ReactNode—A button or link at the end of the row.
dismissiblebooleanfalseAdds 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

components/ui/banner.tsx
"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 }