# Announcement bar

Slim top bar that rotates promo messages with a link and collapses smoothly when dismissed.

- Type: component
- Category: Feedback
- Tier: Free
- Dependencies: lucide-react, motion

## Install

```bash
npx shadcn@latest add https://facadeui.com/r/announcement-bar.json
```

## Usage

```tsx
import { AnnouncementBar } from "@/components/facade/announcement-bar";

export default function Page() {
  return <AnnouncementBar />;
}
```

## Source

```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;
```
