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 · lighticonslucide-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 };