Announcement Bar

Top-of-page banner with default / primary / muted tones, optional dismiss button and localStorage persistence.

Example

Installation

npx shadcn@latest add https://hirael.com/r/announcement-bar.json

API

<AnnouncementBar />

+ native element props
PropTypeDefault
tone"default" | "muted" | "primary" | null"default"
dismissiblebooleanfalse
open

Controlled-open. If provided, internal state is bypassed.

boolean
onDismiss

Fires when the user dismisses via the close button.

(() => void)
storageKey

localStorage key. When set, the dismissed state is persisted across reloads.

string

<AnnouncementBarBadge />

+ native element props

No props of its own — forwards everything to the underlying element.

<AnnouncementBarLink />

+ native element props

No props of its own — forwards everything to the underlying element.

Component source

"use client";

import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";

import { cn } from "@/lib/utils";
import { Button } from "@/registry/hirael/ui/button";

type AnnouncementBarProps = React.ComponentProps<"div"> &
  VariantProps<typeof announcementBarVariants> & {
    dismissible?: boolean;
    /** Controlled-open. If provided, internal state is bypassed. */
    open?: boolean;
    /** Fires when the user dismisses via the close button. */
    onDismiss?: () => void;
    /** localStorage key. When set, the dismissed state is persisted across reloads. */
    storageKey?: string;
  };

const announcementBarVariants = cva(
  "relative isolate flex w-full items-center justify-center gap-3 border-b px-4 py-2 text-sm",
  {
    variants: {
      tone: {
        default: "border-border bg-card text-card-foreground",
        primary: "border-foreground/15 bg-foreground text-background",
        muted: "border-border bg-muted text-foreground",
      },
    },
    defaultVariants: {
      tone: "default",
    },
  },
);

const noopUnsubscribe = () => () => {};

// Server render returns false (visible by default) so the SSR HTML and the
// hydration pass agree without warnings. Once hydration is committed, the
// real storage value is read — returning users see the bar hide.
function useStoredDismiss(storageKey?: string) {
  const subscribe = React.useCallback(
    (cb: () => void) => {
      if (!storageKey || typeof window === "undefined")
        return noopUnsubscribe();
      const handler = (e: StorageEvent) => {
        if (e.key === storageKey) cb();
      };
      window.addEventListener("storage", handler);
      return () => window.removeEventListener("storage", handler);
    },
    [storageKey],
  );
  const getSnapshot = React.useCallback(() => {
    if (!storageKey || typeof window === "undefined") return false;
    try {
      return window.localStorage.getItem(storageKey) === "1";
    } catch {
      return false;
    }
  }, [storageKey]);
  const getServerSnapshot = React.useCallback(() => false, []);
  return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}

function AnnouncementBar({
  className,
  tone = "default",
  dismissible = false,
  open,
  onDismiss,
  storageKey,
  children,
  ...props
}: AnnouncementBarProps) {
  const storedDismissed = useStoredDismiss(storageKey);
  const [localDismissed, setLocalDismissed] = React.useState(false);

  const isControlled = open !== undefined;
  const dismissed = isControlled ? !open : storedDismissed || localDismissed;

  if (dismissed) return null;

  const handleDismiss = () => {
    if (!isControlled) setLocalDismissed(true);
    if (storageKey && typeof window !== "undefined") {
      try {
        window.localStorage.setItem(storageKey, "1");
      } catch {}
    }
    onDismiss?.();
  };

  const isPrimary = tone === "primary";

  return (
    <div
      role="region"
      aria-label="Site announcement"
      data-slot="announcement-bar"
      data-tone={tone}
      className={cn(announcementBarVariants({ tone }), className)}
      {...props}
    >
      <div className="flex flex-1 items-center justify-center gap-2 text-center">
        {children}
      </div>
      {dismissible && (
        <Button
          type="button"
          variant="ghost"
          size="icon"
          onClick={handleDismiss}
          aria-label="Dismiss announcement"
          className={cn(
            "absolute end-2 size-7",
            isPrimary &&
              "text-background/70 hover:bg-background/10 hover:text-background",
          )}
        >
          <X className="size-3.5" />
        </Button>
      )}
    </div>
  );
}

type AnnouncementBarBadgeProps = React.ComponentProps<"span">;

function AnnouncementBarBadge({
  className,
  ...props
}: AnnouncementBarBadgeProps) {
  return (
    <span
      data-slot="announcement-bar-badge"
      className={cn(
        "inline-flex items-center rounded-sm border border-current/20 px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-[0.12em] opacity-80",
        className,
      )}
      {...props}
    />
  );
}

type AnnouncementBarLinkProps = React.ComponentProps<"a">;

function AnnouncementBarLink({
  className,
  ...props
}: AnnouncementBarLinkProps) {
  return (
    <a
      data-slot="announcement-bar-link"
      className={cn(
        "inline-flex items-center gap-1 underline underline-offset-4 transition-opacity hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
        className,
      )}
      {...props}
    />
  );
}

export { AnnouncementBar, AnnouncementBarBadge, AnnouncementBarLink };

Dependencies

shadcn registry

button

npm

lucide-reactclass-variance-authority