uniquel
componentannouncement-barsource: ok · hit
Announcement Bar
Restrained product update strip with gradient sheen and a clean collapse dismiss.
npx shadcn add "https://uniquel.io/r/announcement-bar.json"free
Where it goes
banner
Visual style
gradientminimal
Interaction
accordion
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumanimationframer-motion
Files1 · source cached 10/14/2026, 3:19:38 PM
components/navigation/AnnouncementBar.tsx
'use client';
import { AnimatePresence, motion } from 'framer-motion';
import { useState, type ReactNode } from 'react';
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
//
// message Bar copy
// action Optional CTA — { label, href?, onClick? }
// icon Optional leading mark
// dismissible Show collapse dismiss control
// tone neutral | accent | inverse
// sticky Stick to the top of the viewport
// onDismiss Fired after collapse
// className Styles for the outer shell
//
type AnnouncementAction = {
label: string;
href?: string;
onClick?: () => void;
};
type AnnouncementBarProps = {
message?: string;
action?: AnnouncementAction;
icon?: ReactNode;
dismissible?: boolean;
tone?: 'neutral' | 'accent' | 'inverse';
sticky?: boolean;
onDismiss?: () => void;
className?: string;
};
const TONE = {
neutral:
'border-[color:var(--color-border,#e5e7eb)] bg-[var(--color-background,#fff)] text-[color:var(--color-foreground,#1f2937)]',
accent:
'border-rose-200/60 bg-linear-to-r from-rose-50 via-white to-amber-50 text-rose-950',
inverse:
'border-gray-800 bg-linear-to-r from-gray-950 via-gray-900 to-gray-950 text-white',
};
export default function AnnouncementBar({
message = 'New: Coverflow Gallery is live — explore the catalog.',
action = { label: "See what's new", href: '#' },
icon,
dismissible = true,
tone = 'neutral',
sticky = false,
onDismiss,
className = '',
}: AnnouncementBarProps) {
const [open, setOpen] = useState(true);
const inverse = tone === 'inverse';
return (
<AnimatePresence initial={false}>
{open ? (
<motion.div
className={[
'w-full overflow-hidden border-b',
sticky ? 'sticky top-0 z-50' : '',
TONE[tone],
className,
]
.filter(Boolean)
.join(' ')}
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.35, ease: [0.22, 1, 0.36, 1] }}
>
<div className='relative mx-auto flex max-w-5xl items-center justify-center gap-3 px-4 py-2.5 sm:px-6'>
<span
className='pointer-events-none absolute inset-0 bg-[linear-gradient(105deg,transparent_40%,rgba(255,255,255,0.35)_50%,transparent_60%)] bg-size-[200%_100%] opacity-40'
aria-hidden
/>
{icon ? (
<span className='relative shrink-0 text-sm' aria-hidden>
{icon}
</span>
) : (
<span
className={[
'relative h-1.5 w-1.5 shrink-0 rounded-full',
inverse ? 'bg-white/70' : 'bg-rose-400',
].join(' ')}
aria-hidden
/>
)}
<p className='relative text-center text-xs font-medium tracking-wide sm:text-[13px]'>
{message}
{action ? (
<>
{' '}
<a
href={action.href ?? '#'}
onClick={action.onClick}
className={[
'inline-flex items-center gap-1 underline-offset-4 transition-[opacity,transform] hover:underline',
inverse ? 'text-white/90' : 'text-gray-900',
].join(' ')}
>
{action.label}
<motion.span
aria-hidden
className='inline-block'
whileHover={{ x: 2 }}
>
→
</motion.span>
</a>
</>
) : null}
</p>
{dismissible ? (
<button
type='button'
aria-label='Dismiss announcement'
onClick={() => {
setOpen(false);
window.setTimeout(() => onDismiss?.(), 320);
}}
className={[
'absolute right-3 inline-flex h-7 w-7 items-center justify-center rounded-full transition-colors sm:right-4',
inverse
? 'text-white/50 hover:bg-white/10 hover:text-white'
: 'text-[color:var(--color-muted-foreground,#9ca3af)] hover:bg-[var(--color-muted,#f3f4f6)] hover:text-[color:var(--color-foreground,#374151)]',
].join(' ')}
>
<svg viewBox='0 0 12 12' className='h-3 w-3' aria-hidden>
<path
d='M3 3l6 6M9 3L3 9'
stroke='currentColor'
strokeWidth='1.4'
strokeLinecap='round'
/>
</svg>
</button>
) : null}
</div>
</motion.div>
) : null}
</AnimatePresence>
);
}