Skip to content
6

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

Usage

import { AnnouncementBar, AnnouncementBarBadge, AnnouncementBarLink } from "@/components/announcement-bar"

Paths assume the default @/ alias from your components.json. The example above shows the parts composed.

API

<AnnouncementBar />

+ native element props
PropTypeDefault
dismissiblebooleanfalse
open

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

booleannone
onDismiss

Fires when the user dismisses via the close button.

(() => void)none
storageKey

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

stringnone
tone"default" | "muted" | "primary" | null'default'

<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/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 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.
const 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);
};

const 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'>;

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 font-mono text-[10px] uppercase tracking-[0.12em] 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: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