Banner

Full-width announcement bar with variants, dismissal, and an optional action slot.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/banner.json

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

Code

"use client"; import { type ButtonHTMLAttributes, type ComponentPropsWithoutRef, forwardRef, type ReactNode, useCallback, useState, useSyncExternalStore, } from "react"; import { Slot } from "@radix-ui/react-slot"; import { cva, type VariantProps } from "class-variance-authority"; import { X } from "lucide-react"; import { cn } from "../../lib/utils"; const bannerVariants = cva( "flex w-full items-start gap-3 border-b px-4 py-3 text-sm transition-all motion-safe:animate-in motion-safe:slide-in-from-top-1", { defaultVariants: { variant: "info", }, variants: { variant: { destructive: "border-destructive/50 bg-destructive/10 text-destructive dark:border-destructive [&_svg]:text-destructive", info: "border-border bg-muted text-foreground [&_svg]:text-muted-foreground", success: "border-emerald-500/40 bg-emerald-500/10 text-emerald-900 dark:text-emerald-200 [&_svg]:text-emerald-600 dark:[&_svg]:text-emerald-300", warning: "border-amber-500/40 bg-amber-500/10 text-amber-900 dark:text-amber-200 [&_svg]:text-amber-600 dark:[&_svg]:text-amber-300", }, }, }, ); /** * Visual variant for {@link Banner}. Drives both color treatment and the ARIA * role: `warning` and `destructive` render as `role="alert"`, others as * `role="status"`. * * @public */ export type BannerVariant = "destructive" | "info" | "success" | "warning"; const URGENT_VARIANTS: ReadonlySet<BannerVariant> = new Set([ "destructive", "warning", ]); const STORAGE_PREFIX = "vllnt-ui:banner-dismissed:"; function safeStorageGet(key: string): null | string { if (typeof window === "undefined") return null; try { return window.localStorage.getItem(key); } catch { return null; } } function safeStorageSet(key: string, value: string): void { if (typeof window === "undefined") return; try { window.localStorage.setItem(key, value); } catch { return; } } function subscribeToStorage(callback: () => void): () => void { if (typeof window === "undefined") { return () => { return; }; } window.addEventListener("storage", callback); return () => { window.removeEventListener("storage", callback); }; } function usePersistedDismissed(storageKey: string | undefined): boolean { const getClientSnapshot = useCallback(() => { if (!storageKey) return false; return safeStorageGet(storageKey) === "1"; }, [storageKey]); const getServerSnapshot = useCallback(() => false, []); return useSyncExternalStore( subscribeToStorage, getClientSnapshot, getServerSnapshot, ); } /** * Props for {@link Banner}. * * @public */ export type BannerProps = { /** When true, renders a dismiss control. */ dismissible?: boolean; /** Accessible label for the dismiss control. Defaults to `"Dismiss"`. */ dismissLabel?: string; /** Optional icon rendered to the left of the message. */ icon?: ReactNode; /** * Stable identifier used as the localStorage key when persisting dismissal. * Pair with `persistDismissal` to remember a user's choice. */ id?: string; /** Fires after the user clicks the dismiss control. */ onDismiss?: () => void; /** * When true alongside `id`, persists dismissal in localStorage so the * banner remains hidden across reloads. */ persistDismissal?: boolean; } & ComponentPropsWithoutRef<"div"> & VariantProps<typeof bannerVariants>; /** * Full-width announcement bar. * * Renders a horizontal bar with variant-driven color treatment, an optional * icon slot, and an optional dismiss control. Pair `id` with * `persistDismissal` to remember dismissal across sessions in localStorage. * * @example * ```tsx * <Banner variant="warning" dismissible icon={<AlertTriangle />}> * Scheduled maintenance tonight at 11pm UTC. * <BannerAction onClick={openStatus}>View status</BannerAction> * </Banner> * ``` * * @public */ export const Banner = forwardRef<HTMLDivElement, BannerProps>( ( { children, className, dismissible = false, dismissLabel = "Dismiss", icon, id, onDismiss, persistDismissal = false, role: roleOverride, variant, ...rest }, ref, ) => { const storageKey = persistDismissal && id ? `${STORAGE_PREFIX}${id}` : undefined; const persistedDismissed = usePersistedDismissed(storageKey); const [locallyDismissed, setLocallyDismissed] = useState(false); const handleDismiss = useCallback(() => { setLocallyDismissed(true); if (storageKey) safeStorageSet(storageKey, "1"); onDismiss?.(); }, [onDismiss, storageKey]); if (locallyDismissed || persistedDismissed) return null; const resolvedVariant: BannerVariant = variant ?? "info"; const role = roleOverride ?? (URGENT_VARIANTS.has(resolvedVariant) ? "alert" : "status"); return ( <div className={cn(bannerVariants({ variant }), className)} id={id} ref={ref} role={role} {...rest} > {icon ? ( <span aria-hidden="true" className="mt-0.5 flex size-4 shrink-0 items-center justify-center [&>svg]:h-4 [&>svg]:w-4" > {icon} </span> ) : null} <div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-3 gap-y-1"> {children} </div> {dismissible ? ( <button aria-label={dismissLabel} className="ml-auto inline-flex size-6 shrink-0 items-center justify-center rounded-md opacity-70 transition-colors hover:bg-foreground/10 hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" onClick={handleDismiss} type="button" > <X aria-hidden="true" className="size-4" /> </button> ) : null} </div> ); }, ); Banner.displayName = "Banner"; /** * Props for {@link BannerAction}. * * @public */ export type BannerActionProps = { /** Render the wrapped child element instead of a `<button>`. */ asChild?: boolean; } & ButtonHTMLAttributes<HTMLButtonElement>; /** * Action slot used inside a {@link Banner} body. Renders a styled button by * default, or composes onto a passed child element when `asChild` is true * (e.g. wrap an `<a>` for link actions). * * @public */ export const BannerAction = forwardRef<HTMLButtonElement, BannerActionProps>( ({ asChild = false, children, className, type, ...rest }, ref) => { const Comp = asChild ? Slot : "button"; const buttonProps: ButtonHTMLAttributes<HTMLButtonElement> = asChild ? rest : { type: type ?? "button", ...rest }; return ( <Comp className={cn( "inline-flex h-7 items-center justify-center rounded-md border border-foreground/20 bg-transparent px-3 text-xs font-medium underline-offset-4 transition-colors hover:bg-foreground/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", className, )} ref={ref} {...buttonProps} > {children} </Comp> ); }, ); BannerAction.displayName = "BannerAction"; export { bannerVariants };

Dependencies

  • @vllnt/ui@^0.2.1
  • lucide-react
  • class-variance-authority
  • @radix-ui/react-slot