shadimage
hirael
componentannouncement-barsource: ok · hit

Announcement Bar

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

npx shadcn add "https://hirael.com/r/announcement-bar.json"
free

Where it goes

banner

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
iconslucide-react

Registry dependencies

button

Files1 · source cached 10/15/2026, 5:38:01 AM

registry/hirael/bases/radix/components/announcement-bar.tsx
// Announcement Bar from Hirael <https://hirael.com/components/display/announcement-bar>
// MIT · Mohammad Shehadeh · https://github.com/MohammadShehadeh/hirael

'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/bases/radix/ui/button';

interface AnnouncementBarProps extends React.ComponentProps<'div'>, VariantProps<typeof announcementBarVariants> {
  dismissible?: boolean;
  /** Controlled-open. If provided, internal state is bypassed. */
  open?: boolean;
  /** Fires with `false` when the user dismisses via the close button. */
  onOpenChange?: (open: boolean) => void;
  /** Fires when the user dismisses via the close button. Same moment as `onOpenChange(false)`. */
  onDismiss?: () => void;
  /** Accessible name of the close button. */
  dismissLabel?: string;
  /** 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-primary bg-primary text-primary-foreground',
        muted: 'border-border bg-muted text-foreground',
      },
    },
    defaultVariants: {
      tone: 'default',
    },
  },
);

// Server snapshot is false (visible) so hydration agrees; a returning user's bar hides after it.
const useStoredDismiss = (storageKey?: string) => {
  const subscribe = React.useCallback(
    (cb: () => void) => {
      if (!storageKey) return () => {};
      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) return false;
    try {
      return window.localStorage.getItem(storageKey) === '1';
    } catch {
      return false;
    }
  }, [storageKey]);
  const getServerSnapshot = React.useCallback(() => false, []);

  return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
};

const AnnouncementBar = ({
  className,
  tone = 'default',
  dismissible = false,
  open,
  onOpenChange,
  onDismiss,
  dismissLabel = 'Dismiss announcement',
  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 {}
    }
    onOpenChange?.(false);
    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={dismissLabel}
          className={cn(
            'absolute end-2 size-7',
            isPrimary && 'text-primary-foreground/70 hover:bg-primary-foreground/10 hover:text-primary-foreground',
          )}
        >
          <X className="size-3.5" />
        </Button>
      )}
    </div>
  );
};

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

const 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 text-xs uppercase opacity-80',
        className,
      )}
      {...props}
    />
  );
};

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

const 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:ring-2 focus-visible:ring-ring focus-visible:outline-none',
        className,
      )}
      {...props}
    />
  );
};

export { AnnouncementBar, AnnouncementBarBadge, AnnouncementBarLink };

More from hirael

Browse all components in this library →