Banner

Barre d'annonce pleine largeur avec variantes, fermeture et un emplacement d'action optionnel. Installez Banner depuis le registre VLLNT UI avec la CLI shadcn.

Signaler un bug

Barre d'annonce pleine largeur avec variantes, fermeture et un emplacement d'action optionnel. Fait partie de la famille core dans VLLNT UI, il est publie comme entree de registre lisible par machine — copiez la source directement dans votre application avec la CLI shadcn et possedez-la, sans dependance d'execution a une bibliotheque de composants.

Apercu

Basculez entre clair et sombre pour inspecter l'apercu Storybook integre.

Installation

Ajoutez Banner a votre projet avec la CLI shadcn. La source arrive dans votre code, prete a etre adaptee :

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

Source

"use client"; import { type ButtonHTMLAttributes, type ComponentPropsWithoutRef, 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 = ({ children, className, dismissible = false, dismissLabel = "Dismiss", icon, id, onDismiss, persistDismissal = false, ref, role: roleOverride, variant, ...rest }: BannerProps & { ref?: React.Ref<HTMLDivElement> }) => { 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 = ({ asChild = false, children, className, ref, type, ...rest }: BannerActionProps & { ref?: React.Ref<HTMLButtonElement> }) => { 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 };

Stories

Explorez chaque variante et etat dans le Storybook interactif :

Apercu

Basculez entre clair et sombre pour inspecter l'apercu Storybook integre.

Dependances

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