Browse the library
Components / Feedback
Announcement bar
FreeSlim top bar that rotates promo messages with a link and collapses smoothly when dismissed.
components/facade/announcement-bar.tsx
"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ArrowRight, X } from "lucide-react";
import { cn } from "@/lib/utils";
export interface Announcement {
text: string;
/** Optional short lead, rendered bold, e.g. "New". */
badge?: string;
href?: string;
linkLabel?: string;
}
export interface AnnouncementBarProps {
/** Messages to rotate through. Defaults to a small local-business example set. */
messages?: Announcement[];
/** Milliseconds per message. */
interval?: number;
dismissible?: boolean;
/** Called after the bar has collapsed. */
onDismiss?: () => void;
/** Visual style. */
tone?: "brand" | "dark" | "subtle";
className?: string;
}
const TONES = {
brand: "bg-brand text-brand-foreground",
dark: "bg-primary text-primary-foreground",
subtle: "bg-accent text-accent-foreground border-b border-border",
} as const;
const DEFAULT_MESSAGES: Announcement[] = [
{ badge: "New", text: "Now booking spring appointments across Austin.", href: "#book", linkLabel: "Book now" },
{ badge: "Offer", text: "Free estimates on every project through March 31.", href: "#quote", linkLabel: "Get a quote" },
];
export function AnnouncementBar({
messages = DEFAULT_MESSAGES,
interval = 4500,
dismissible = true,
onDismiss,
tone = "dark",
className,
}: AnnouncementBarProps) {
const reduce = useReducedMotion();
const [index, setIndex] = React.useState(0);
const [open, setOpen] = React.useState(true);
const [paused, setPaused] = React.useState(false);
React.useEffect(() => {
if (messages.length < 2 || paused || !open) return;
const id = window.setInterval(() => setIndex((i) => (i + 1) % messages.length), interval);
return () => window.clearInterval(id);
}, [messages.length, interval, paused, open]);
const msg = messages[index % Math.max(1, messages.length)];
if (!msg) return null;
return (
<AnimatePresence initial={false} onExitComplete={onDismiss}>
{open && (
<motion.div
key="bar"
role="region"
aria-label="Announcements"
initial={{ height: 0 }}
animate={{ height: "auto" }}
exit={{ height: 0, transition: { duration: reduce ? 0 : 0.35, ease: [0.22, 1, 0.36, 1] } }}
className={cn("relative w-full overflow-hidden", TONES[tone], className)}
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
onFocus={() => setPaused(true)}
onBlur={() => setPaused(false)}
>
<motion.div
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="mx-auto flex h-10 max-w-6xl items-center gap-3 px-4 sm:px-6"
>
{/* Spacer keeps text centered against the dismiss button */}
{dismissible && <span className="w-7 shrink-0" aria-hidden />}
<div className="relative flex h-full min-w-0 flex-1 items-center justify-center" aria-live="polite">
<AnimatePresence mode="popLayout" initial={false}>
<motion.p
key={index}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8, filter: "blur(4px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8, filter: "blur(4px)" }}
transition={{ duration: 0.45, ease: [0.22, 1, 0.36, 1] }}
className="flex min-w-0 items-center gap-2 text-[13px]"
>
{msg.badge && (
<span className="hidden shrink-0 rounded-full bg-current/15 px-2 py-px text-[10px] font-semibold uppercase tracking-wider ring-1 ring-current/25 sm:inline">
{msg.badge}
</span>
)}
<span className="truncate">{msg.text}</span>
{msg.href && (
<a
href={msg.href}
className="group inline-flex shrink-0 items-center gap-1 rounded font-medium underline decoration-current/40 underline-offset-4 transition-colors hover:decoration-current focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/60"
>
{msg.linkLabel ?? "Learn more"}
<ArrowRight
className="size-3.5 transition-transform group-hover:translate-x-0.5"
aria-hidden
/>
</a>
)}
</motion.p>
</AnimatePresence>
</div>
{messages.length > 1 && (
<div className="absolute bottom-1 left-1/2 hidden -translate-x-1/2 gap-1 sm:flex" aria-hidden>
{messages.map((_, i) => (
<span
key={i}
className={cn(
"h-0.5 rounded-full bg-current transition-all duration-500",
i === index ? "w-3 opacity-70" : "w-1 opacity-25",
)}
/>
))}
</div>
)}
{dismissible && (
<button
type="button"
onClick={() => setOpen(false)}
aria-label="Dismiss announcement"
className="grid size-7 shrink-0 place-items-center rounded-full opacity-70 transition hover:bg-current/10 hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/60"
>
<X className="size-4" aria-hidden />
</button>
)}
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
export default AnnouncementBar;app/page.tsx
"use client";
import * as React from "react";
import { Menu, Wrench } from "lucide-react";
import { AnnouncementBar, type Announcement } from "@/components/facade/announcement-bar";
const messages: Announcement[] = [
{
badge: "Fall special",
text: "$79 furnace tune-up through November 30.",
href: "#book",
linkLabel: "Book now",
},
{
badge: "24/7",
text: "No heat? Emergency service across Denver metro, no overtime fees.",
href: "tel:+13035550133",
linkLabel: "Call (303) 555-0133",
},
{
badge: "Financing",
text: "0% APR for 18 months on new systems, approval in minutes.",
href: "#financing",
linkLabel: "Check rates",
},
];
export default function Demo() {
const [key, setKey] = React.useState(0);
const [dismissed, setDismissed] = React.useState(false);
return (
<div className="mx-auto w-full max-w-4xl p-4">
<div className="overflow-hidden rounded-2xl border border-border bg-background shadow-sm">
<AnnouncementBar key={key} messages={messages} onDismiss={() => setDismissed(true)} />
<header className="flex h-14 items-center justify-between border-b border-border px-5">
<div className="flex items-center gap-2 text-sm font-semibold tracking-tight">
<span className="grid size-7 place-items-center rounded-lg bg-brand text-brand-foreground">
<Wrench className="size-3.5" aria-hidden />
</span>
Front Range Heating & Air
</div>
<nav className="hidden items-center gap-6 text-sm text-muted-foreground sm:flex">
<span>Services</span>
<span>Maintenance plans</span>
<span>Reviews</span>
<span className="rounded-full bg-primary px-3.5 py-1.5 text-primary-foreground">Get a quote</span>
</nav>
<Menu className="size-5 text-muted-foreground sm:hidden" aria-hidden />
</header>
<div className="px-5 py-10">
<div className="h-3 w-24 rounded-full bg-muted" />
<div className="mt-4 h-7 w-3/4 max-w-md rounded-lg bg-muted" />
<div className="mt-2 h-7 w-1/2 max-w-xs rounded-lg bg-muted" />
{dismissed && (
<button
type="button"
onClick={() => {
setDismissed(false);
setKey((k) => k + 1);
}}
className="mt-6 rounded-full border border-border px-3 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Show announcement again
</button>
)}
</div>
</div>
</div>
);
}Installation
$
npx shadcn@latest add https://facadeui.com/r/announcement-bar.jsonUsage
import { AnnouncementBar } from "@/components/facade/announcement-bar";
export default function Page() {
return <AnnouncementBar />;
}Props
AnnouncementBarProps is exported, so you can extend or wrap it. Every prop has a default; <AnnouncementBar /> renders the example above.
| Prop | Type | Description |
|---|---|---|
| messages | Announcement[] | Messages to rotate through. Defaults to a small local-business example set. |
| interval | number | Milliseconds per message. |
| dismissible | boolean | |
| onDismiss | () => void | Called after the bar has collapsed. |
| tone | "brand" | "dark" | "subtle" | Visual style. |
| className | string |
Ask your agent
With the Facade MCP server connected, this prompt is enough:
Add the Facade announcement bar to this page and adapt the copy to our business.