Bloom Logo

Banner

A global announcement or notification bar that spans the width of its container. Supports variants, radius, dismissal, action slots, custom icons, and auto-playing announcement carousels with a progress bar.

importimport { Banner } from "@/components/ui/banner/banner";
$npx @bloomui-react/cli add banner
banner.tsx
"use client";

import { Icon } from "@iconify/react";
import React from "react";
import { cn } from "@/lib/utils";

export type BannerRadius = "none" | "sm" | "md" | "lg" | "xl" | "2xl" | "full";

export interface AnnouncementItem {
  id: string | number;
  content: React.ReactNode;
  icon?: React.ReactNode;
  action?: React.ReactNode;
}

export interface BannerProps extends React.HTMLAttributes<HTMLDivElement> {
  variant?: "default" | "primary" | "success" | "warning" | "danger";
  position?: "static" | "sticky-top" | "sticky-bottom";
  radius?: BannerRadius;
  announcements?: AnnouncementItem[];
  storageKey?: string;
  isDismissible?: boolean;
  onDismiss?: () => void;
  action?: React.ReactNode;
  icon?: React.ReactNode;
  hideIcon?: boolean;
  customIcon?: React.ReactNode;
  autoPlay?: boolean;
  autoPlayInterval?: number;
  showProgress?: boolean;
}

const radiusMap: Record<BannerRadius, string> = {
  none: "rounded-none",
  sm: "rounded-sm",
  md: "rounded-md",
  lg: "rounded-lg",
  xl: "rounded-xl",
  "2xl": "rounded-2xl",
  full: "rounded-full",
};

export function Banner({
  children,
  variant = "default",
  position = "static",
  radius = "2xl",
  announcements,
  storageKey,
  isDismissible,
  onDismiss,
  action,
  icon,
  hideIcon = false,
  customIcon,
  autoPlay = false,
  autoPlayInterval = 4000,
  showProgress = false,
  className,
  ...props
}: BannerProps) {
  const [isDismissed, setIsDismissed] = React.useState(false);
  const [currentIndex, setCurrentIndex] = React.useState(0);
  const [progress, setProgress] = React.useState(0);

  React.useEffect(() => {
    if (storageKey && typeof window !== "undefined") {
      const dismissed = localStorage.getItem(
        `bloom-banner-dismissed-${storageKey}`,
      );
      if (dismissed === "true") {
        setIsDismissed(true);
      }
    }
  }, [storageKey]);

  const hasCarousel = announcements && announcements.length > 0;
  const count = announcements?.length ?? 0;

  const nextAnnouncement = React.useCallback(() => {
    if (hasCarousel) {
      setCurrentIndex((prev) => (prev + 1) % count);
      setProgress(0);
    }
  }, [hasCarousel, count]);

  const prevAnnouncement = React.useCallback(() => {
    if (hasCarousel) {
      setCurrentIndex((prev) => (prev === 0 ? count - 1 : prev - 1));
      setProgress(0);
    }
  }, [hasCarousel, count]);

  React.useEffect(() => {
    if (!hasCarousel || !autoPlay || count <= 1) return;

    if (showProgress) {
      setProgress(0);
      const step = 50;
      const increment = (step / autoPlayInterval) * 100;
      const interval = setInterval(() => {
        setProgress((prev) => {
          if (prev >= 100) {
            return 0;
          }
          return prev + increment;
        });
      }, step);
      const advance = setTimeout(nextAnnouncement, autoPlayInterval);
      return () => {
        clearInterval(interval);
        clearTimeout(advance);
      };
    }

    const timer = setTimeout(nextAnnouncement, autoPlayInterval);
    return () => clearTimeout(timer);
  }, [
    hasCarousel,
    autoPlay,
    autoPlayInterval,
    count,
    currentIndex,
    showProgress,
    nextAnnouncement,
  ]);

  const handleDismiss = React.useCallback(() => {
    setIsDismissed(true);
    if (storageKey && typeof window !== "undefined") {
      localStorage.setItem(`bloom-banner-dismissed-${storageKey}`, "true");
    }
    onDismiss?.();
  }, [storageKey, onDismiss]);

  if (isDismissed) return null;

  const variantStyles = {
    default: {
      accent: "bg-zinc-500 dark:bg-zinc-400",
      icon: "bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-300 border-zinc-200 dark:border-zinc-700",
      progress: "bg-zinc-500",
      bg: "bg-white dark:bg-zinc-900",
      text: "text-zinc-500 dark:text-zinc-400",
    },
    primary: {
      accent: "bg-sky-500",
      icon: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
      progress: "bg-sky-500",
      bg: "bg-white dark:bg-zinc-900",
      text: "text-sky-600 dark:text-sky-400",
    },
    success: {
      accent: "bg-emerald-500",
      icon: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
      progress: "bg-emerald-500",
      bg: "bg-white dark:bg-zinc-900",
      text: "text-emerald-600 dark:text-emerald-400",
    },
    warning: {
      accent: "bg-amber-500",
      icon: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
      progress: "bg-amber-500",
      bg: "bg-white dark:bg-zinc-900",
      text: "text-amber-600 dark:text-amber-400",
    },
    danger: {
      accent: "bg-rose-500",
      icon: "bg-rose-500/10 text-rose-600 dark:text-rose-400 border-rose-500/20",
      progress: "bg-rose-500",
      bg: "bg-white dark:bg-zinc-900",
      text: "text-rose-600 dark:text-rose-400",
    },
  };

  const positionStyles = {
    static: "relative",
    "sticky-top": "sticky top-0 z-40 rounded-none border-x-0 border-t-0",
    "sticky-bottom": "sticky bottom-0 z-40 rounded-none border-x-0 border-b-0",
  };

  const styles = variantStyles[variant];
  const isSticky = position !== "static";

  const currentAnnouncement = hasCarousel ? announcements[currentIndex] : null;
  const activeContent = currentAnnouncement
    ? currentAnnouncement.content
    : children;
  const activeIcon = currentAnnouncement
    ? (currentAnnouncement.icon ?? icon)
    : icon;
  const activeAction = currentAnnouncement
    ? (currentAnnouncement.action ?? action)
    : action;

  const resolvedIcon = customIcon ?? activeIcon;
  const showIconSlot = !hideIcon && resolvedIcon;

  return (
    <div
      className={cn(
        "relative flex items-center gap-3 overflow-hidden border border-zinc-200 dark:border-zinc-800 shadow-sm transition-all duration-300",
        styles.bg,
        isSticky ? positionStyles[position] : radiusMap[radius],
        !isSticky && "p-3 px-4",
        isSticky && "px-4 py-3",
        className,
      )}
      {...props}
    >
      <div
        className={cn("absolute bottom-0 left-0 top-0 w-[3px]", styles.accent)}
      />

      {showProgress && hasCarousel && count > 1 && (
        <div className="absolute bottom-0 left-0 right-0 h-[2px] bg-zinc-100 dark:bg-zinc-800">
          <div
            className={cn("h-full transition-none", styles.progress)}
            style={{ width: `${progress}%` }}
          />
        </div>
      )}

      {showIconSlot && (
        <div
          className={cn(
            "flex size-8 shrink-0 items-center justify-center rounded-lg border",
            styles.icon,
          )}
        >
          {resolvedIcon}
        </div>
      )}

      <div className="flex-1 text-sm text-zinc-700 dark:text-zinc-300 leading-snug">
        {activeContent}
      </div>

      {hasCarousel && count > 1 && (
        <div
          className={cn(
            "flex items-center gap-0.5 shrink-0 text-xs font-mono",
            styles.text,
          )}
        >
          <button
            type="button"
            onClick={prevAnnouncement}
            className="p-1.5 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors cursor-pointer"
            aria-label="Previous announcement"
          >
            <Icon icon="hugeicons:arrow-left-01" className="size-3.5" />
          </button>
          <span className="tabular-nums px-0.5">
            {currentIndex + 1}/{count}
          </span>
          <button
            type="button"
            onClick={nextAnnouncement}
            className="p-1.5 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors cursor-pointer"
            aria-label="Next announcement"
          >
            <Icon icon="hugeicons:arrow-right-01" className="size-3.5" />
          </button>
        </div>
      )}

      {activeAction && <div className="flex-shrink-0 ml-1">{activeAction}</div>}

      {isDismissible && (
        <button
          type="button"
          onClick={handleDismiss}
          className={cn(
            "flex-shrink-0 rounded-lg p-1.5 transition-colors cursor-pointer",
            "text-zinc-400 hover:bg-zinc-100 hover:text-zinc-600",
            "dark:hover:bg-zinc-800 dark:hover:text-zinc-300",
          )}
          aria-label="Dismiss banner"
        >
          <Icon icon="hugeicons:cancel-01" className="h-4 w-4" />
        </button>
      )}
    </div>
  );
}

Default

A standard banner for general information.

We have updated our terms of service and privacy policy. Please review them at your earliest convenience.

Variants

Banners come in multiple variants to indicate the severity or type of the message.

variant: default | primary | success | warning | danger
System maintenance scheduled for tonight at 2:00 AM UTC.
A new software update is available for download!
Your data export has successfully completed and is ready to download.
Your subscription will expire in 3 days. Please renew to avoid interruption.
Payment failed. Please update your billing information immediately.

Radius

Control the border radius of the banner. Defaults to 2xl.

radius: none | sm | md | lg | xl | 2xl | full
radius="none"Border radius variant.
radius="sm"Border radius variant.
radius="md"Border radius variant.
radius="lg"Border radius variant.
radius="xl"Border radius variant.
radius="2xl"Border radius variant.

Dismissible

A banner that can be dismissed by the user. Click Trigger Banner to re-show it.

isDismissible: booleanonDismiss: () => void
Check out our new features introduced in the latest release!

With Action

Banners can include custom actions placed to the right of the content.

action: ReactNode
Network connection lost. Some changes may not be saved.

Without Icon

Use hideIcon to suppress the icon slot and render a text-only banner.

hideIcon: boolean
New version available — update now to get the latest features.
Scheduled maintenance tonight from 01:00 to 03:00 UTC.

Custom Icon

Use customIcon to replace the default icon with any ReactNode rendered inside a styled icon container.

customIcon: ReactNode
Identity verified — your account is fully secured.
Suspicious login attempt blocked from an unknown device.

Sticky Positioning & LocalStorage Persistence

Fix banner at top or bottom with position prop (sticky-top, sticky-bottom). Persist dismiss state across reloads with storageKey.

position: static | sticky-top | sticky-bottomstorageKey: string
Persistent Promo Banner: Dismiss state is saved to localStorage!

Props — Banner

Properties to configure the Banner component.

PropTypeDefaultDescription
variant'default' | 'primary' | 'success' | 'warning' | 'danger''default'The visual style variant of the banner.
radius'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full''2xl'Controls the border radius. Ignored when position is sticky.
position'static' | 'sticky-top' | 'sticky-bottom''static'Controls sticky positioning behavior.
iconReactNodeIcon displayed inside a colored badge container at the start.
customIconReactNodeOverrides the icon prop with a custom ReactNode.
hideIconbooleanfalseSuppresses the icon slot entirely.
isDismissiblebooleanfalseShows a dismiss (×) button.
onDismiss() => voidCallback fired when the dismiss button is clicked.
actionReactNodeAction element placed before the dismiss button.
announcementsAnnouncementItem[]Array of announcement items for carousel mode.
autoPlaybooleanfalseAuto-advances the carousel at a fixed interval.
autoPlayIntervalnumber4000Interval in milliseconds between auto-play slides.
showProgressbooleanfalseDisplays a progress bar at the bottom indicating time until the next slide.
storageKeystringlocalStorage key to persist the dismissed state across reloads.