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.jsonUsage
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| Prop | Type | Default |
|---|---|---|
dismissible | boolean | false |
openControlled-open. If provided, internal state is bypassed. | boolean | none |
onDismissFires when the user dismisses via the close button. | (() => void) | none |
storageKeylocalStorage key. When set, the dismissed state is persisted across reloads. | string | none |
tone | "default" | "muted" | "primary" | null | 'default' |
<AnnouncementBarBadge />
+ native element propsNo props of its own; forwards everything to the underlying element.
<AnnouncementBarLink />
+ native element propsNo 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