Slim top bar that rotates messages, holds while read and can be paused.
Notificationsmotion
01Preview
Interfaces people trust with their work
Point at the bar to hold a message, or press pause. It also waits while it’s off screen.
02Install
Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:
npm install motion03Usage
import { AnnouncementBar } from "@/components/ui/announcement-bar";
<AnnouncementBar
dismissible
storageKey="launch-2026-10"
messages={[
{ id: "ui", content: "Stealth UI 2.0 is out with 40 new components", cta: "Read the changelog", href: "/changelog" },
{ id: "qa", content: "Live Q&A on 3 Oct, 17:00 UTC", cta: "Save a seat", href: "/events" },
]}
/>04Source
"use client";
import { AnimatePresence, motion, useMotionValue, useReducedMotion, type MotionValue } from "motion/react";
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import { cn } from "@/lib/cn";
import { ArrowRight, Pause, Play, X } from "@/lib/icons";
import { ease } from "@/lib/motion";
import { useControllableState } from "@/lib/use-controllable-state";
export type Announcement = {
id: string;
content: React.ReactNode;
/** Makes the whole message a link. */
href?: string;
/** Short call to action after the message, like "Read the changelog". Hidden on narrow bars. */
cta?: string;
};
export type AnnouncementBarProps = Omit<React.ComponentProps<"section">, "children"> & {
messages: Announcement[];
/** Milliseconds each message stays before the next. */
interval?: number;
/** Rotate on its own. The person can still pause it. */
autoPlay?: boolean;
index?: number;
defaultIndex?: number;
onIndexChange?: (index: number) => void;
variant?: "subtle" | "inverted";
dismissible?: boolean;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
/** Remember the dismissal on this device. Change the key when the messages change. */
storageKey?: string;
/** Accessible name of the region. */
label?: string;
};
// Remembered dismissal, read after hydration so server and client agree.
const PREFIX = "stealth-announcement:";
const EVENT = "stealth-announcement-change";
const readDismissed = (key: string) => {
try {
return window.localStorage.getItem(PREFIX + key) === "1";
} catch {
return false;
}
};
const subscribe = (fn: () => void) => {
window.addEventListener("storage", fn);
window.addEventListener(EVENT, fn);
return () => {
window.removeEventListener("storage", fn);
window.removeEventListener(EVENT, fn);
};
};
/** Clear a remembered dismissal, so the bar shows again. */
export function resetAnnouncementBar(storageKey: string) {
try {
window.localStorage.removeItem(PREFIX + storageKey);
} catch {}
window.dispatchEvent(new Event(EVENT));
}
export function AnnouncementBar({
messages,
interval = 5000,
autoPlay = true,
index: indexProp,
defaultIndex = 0,
onIndexChange,
variant = "subtle",
dismissible = false,
open: openProp,
defaultOpen = true,
onOpenChange,
storageKey,
label = "Announcements",
className,
...rest
}: AnnouncementBarProps) {
const reduce = useReducedMotion();
const count = messages.length;
const [rawIndex, setIndex] = useControllableState({ value: indexProp, defaultValue: defaultIndex, onChange: onIndexChange });
const index = count ? ((rawIndex % count) + count) % count : 0;
const [open, setOpen] = useControllableState({ value: openProp, defaultValue: defaultOpen, onChange: onOpenChange });
const remembered = useSyncExternalStore(subscribe, () => (storageKey ? readDismissed(storageKey) : false), () => false);
const [paused, setPaused] = useState(false);
const [hovered, setHovered] = useState(false);
const [focused, setFocused] = useState(false);
const [inView, setInView] = useState(true);
const [dir, setDir] = useState(1);
const ref = useRef<HTMLElement>(null);
const progress = useMotionValue(0);
const rotating = autoPlay && count > 1;
// Waits while it is being read, pointed at, or can't be seen.
const running = rotating && !paused && !hovered && !focused && inView;
const go = useCallback(
(next: number, direction: number) => {
setDir(direction);
progress.set(0);
setIndex(((next % count) + count) % count);
},
[count, progress, setIndex],
);
// The clock: accumulates only while running, so pausing keeps the place in the current message.
const advance = useRef(() => {});
useEffect(() => {
advance.current = () => go(index + 1, 1);
});
useEffect(() => {
if (!running) return;
let raf = 0;
let last = performance.now();
const tick = (now: number) => {
const delta = Math.min(now - last, 50);
last = now;
if (document.visibilityState === "visible") {
const next = progress.get() + delta / interval;
if (next >= 1) advance.current();
else progress.set(next);
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [running, interval, progress]);
// Off screen, nothing runs.
useEffect(() => {
const node = ref.current;
if (!node || !rotating) return;
const io = new IntersectionObserver(([entry]) => setInView(entry.isIntersecting));
io.observe(node);
return () => io.disconnect();
}, [rotating]);
const dismiss = () => {
if (storageKey) {
try {
window.localStorage.setItem(PREFIX + storageKey, "1");
} catch {}
window.dispatchEvent(new Event(EVENT));
}
setOpen(false);
};
const inverted = variant === "inverted";
const message = messages[index];
const shown = open && !remembered && count > 0;
return (
<AnimatePresence initial={false}>
{shown && (
<motion.div
key="bar"
className="shrink-0 overflow-hidden"
initial={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}
transition={reduce ? { duration: 0.15 } : { height: { duration: 0.26, ease: ease.inOut }, opacity: { duration: 0.14 } }}
>
<section
ref={ref}
aria-label={label}
aria-roledescription={rotating ? "carousel" : undefined}
data-variant={variant}
data-state={running ? "playing" : "paused"}
onPointerEnter={(e) => e.pointerType === "mouse" && setHovered(true)}
onPointerLeave={() => setHovered(false)}
onFocus={() => setFocused(true)}
onBlur={(e) => !e.currentTarget.contains(e.relatedTarget as Node | null) && setFocused(false)}
className={cn(
"@container relative grid h-9 grid-cols-[minmax(0,1fr)_minmax(0,max-content)_1fr] items-center gap-2 px-2 text-[12.5px]",
inverted ? "bg-fg text-frame" : "border-b border-line bg-raised text-fg",
className,
)}
{...rest}
>
<span aria-hidden />
{/* One message at a time, in a fixed-height window. Announced only when the person is in control. */}
<div className="relative h-9 min-w-0 overflow-hidden" aria-live={running ? "off" : "polite"} aria-atomic="true">
<AnimatePresence initial={false} mode="popLayout" custom={dir}>
<motion.div
key={message.id}
custom={dir}
className="flex h-9 min-w-0 items-center justify-center"
variants={{
enter: (d: number) => (reduce ? { opacity: 0 } : { opacity: 0, y: d * 14, filter: "blur(2px)" }),
center: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: 0.34, ease: ease.out } },
exit: (d: number) => (reduce ? { opacity: 0, transition: { duration: 0.15 } } : { opacity: 0, y: d * -14, filter: "blur(2px)", transition: { duration: 0.22, ease: ease.out } }),
}}
initial="enter"
animate="center"
exit="exit"
role={rotating ? "group" : undefined}
aria-roledescription={rotating ? "slide" : undefined}
aria-label={rotating ? `${index + 1} of ${count}` : undefined}
>
<Message message={message} inverted={inverted} />
</motion.div>
</AnimatePresence>
</div>
<div className="flex items-center justify-end gap-0.5">
{rotating && (
<>
<div className="flex items-center @max-[420px]:hidden" aria-label="Choose an announcement" role="group">
{messages.map((m, i) => (
<Segment
key={m.id}
state={i < index ? "done" : i === index ? "current" : "todo"}
progress={progress}
inverted={inverted}
label={`Show announcement ${i + 1} of ${count}`}
onClick={() => i !== index && go(i, i > index ? 1 : -1)}
/>
))}
</div>
<IconButton inverted={inverted} label={paused ? "Play announcements" : "Pause announcements"} onClick={() => setPaused((p) => !p)}>
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={paused ? "play" : "pause"}
className="grid place-items-center"
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}
animate={{ opacity: 1, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}
transition={{ duration: 0.16, ease: ease.out }}
>
{paused ? <Play size={12} /> : <Pause size={12} />}
</motion.span>
</AnimatePresence>
</IconButton>
</>
)}
{dismissible && (
<IconButton inverted={inverted} label="Dismiss announcements" onClick={dismiss}>
<X size={14} />
</IconButton>
)}
</div>
</section>
</motion.div>
)}
</AnimatePresence>
);
}
function Message({ message, inverted }: { message: Announcement; inverted: boolean }) {
const body = (
<>
<span className="truncate">{message.content}</span>
{message.cta && (
<span className={cn("shrink-0 font-medium @max-[520px]:hidden", inverted ? "text-frame" : "text-fg")}>
<span aria-hidden className={cn("mx-2", inverted ? "opacity-40" : "text-fg-4")}>
·
</span>
{message.cta}
</span>
)}
</>
);
if (!message.href) return <span className={cn("flex min-w-0 items-center", inverted ? "text-frame/80" : "text-fg-2")}>{body}</span>;
return (
<a
href={message.href}
className={cn(
"group/msg flex min-w-0 items-center rounded-md px-1.5 py-1 outline-none transition-colors duration-150",
"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-0",
inverted ? "text-frame/80 hover:text-frame focus-visible:outline-frame/60" : "text-fg-2 hover:text-fg focus-visible:outline-fg-3",
)}
>
{body}
<ArrowRight
size={14}
className={cn(
"ml-1 shrink-0 transition-[translate,opacity] duration-200 ease-out-expo group-hover/msg:translate-x-0.5",
inverted ? "opacity-70 group-hover/msg:opacity-100" : "text-fg-3 group-hover/msg:text-fg",
)}
/>
</a>
);
}
// A 2px track that fills while its message is showing. The hit area is the full bar height.
function Segment({ state, progress, inverted, label, onClick }: { state: "done" | "current" | "todo"; progress: MotionValue<number>; inverted: boolean; label: string; onClick: () => void }) {
return (
<button
type="button"
aria-label={label}
aria-current={state === "current" ? "true" : undefined}
onClick={onClick}
className="group/seg grid h-9 w-5 place-items-center outline-none"
>
<span
className={cn(
"relative h-0.5 w-3.5 overflow-hidden rounded-full transition-[width,background-color] duration-200",
inverted ? "bg-frame/20 group-hover/seg:bg-frame/35" : "bg-fg/10 group-hover/seg:bg-fg/20",
"group-focus-visible/seg:outline-solid group-focus-visible/seg:outline-1 group-focus-visible/seg:outline-offset-4",
inverted ? "group-focus-visible/seg:outline-frame/60" : "group-focus-visible/seg:outline-fg-3",
)}
>
<motion.span className={cn("absolute inset-0 origin-left", inverted ? "bg-frame" : "bg-fg/70")} style={{ scaleX: state === "current" ? progress : state === "done" ? 1 : 0 }} />
</span>
</button>
);
}
function IconButton({ inverted, label, onClick, children }: { inverted: boolean; label: string; onClick: () => void; children: React.ReactNode }) {
return (
<button
type="button"
aria-label={label}
title={label}
onClick={onClick}
className={cn(
"relative grid size-7 shrink-0 place-items-center rounded-md outline-none",
"transition-[background-color,color,scale] duration-150 ease-out active:scale-[0.9] active:duration-75",
"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-0",
"before:absolute before:-inset-2 before:content-[''] pointer-fine:before:hidden",
inverted ? "text-frame/70 hover:bg-frame/10 hover:text-frame focus-visible:outline-frame/60" : "text-fg-3 hover:bg-hover hover:text-fg focus-visible:outline-fg-3",
)}
>
{children}
</button>
);
}05Props
| Prop | Type | Default | Description |
|---|---|---|---|
| messages* | { id: string; content: ReactNode; href?: string; cta?: string }[] | — | One or more messages. With href the whole message is a link; cta is a short verb phrase shown after it on wide bars. |
| interval | number | 5000 | Milliseconds of attention each message gets. Time spent paused doesn't count. |
| autoPlay | boolean | true | Rotate on its own. A single message never rotates and shows no controls. |
| index | number | — | Controlled current message. |
| defaultIndex | number | 0 | Uncontrolled starting message. |
| onIndexChange | (index: number) => void | — | Called when the message changes, by time or by the person. |
| variant | "subtle" | "inverted" | "subtle" | A raised strip with a hairline, or the foreground color inverted for marketing pages. |
| dismissible | boolean | false | Adds a close button; the bar collapses and the page slides up. |
| open | boolean | — | Controlled visibility. |
| defaultOpen | boolean | true | Uncontrolled initial visibility. |
| onOpenChange | (open: boolean) => void | — | Called when dismissed. |
| storageKey | string | — | Remember the dismissal on this device. Change the key when the messages change. resetAnnouncementBar(key) brings it back. |
| label | string | "Announcements" | Accessible name of the region. |
06Notes
Behavior
- The clock only runs while the bar is visible, on screen, in a visible tab, not hovered and not focused; pausing keeps your place in the current message rather than restarting it.
- Each message has a 2px segment that fills as its time runs out, so the rotation is predictable. Segments are buttons: pick one to jump there and the timer restarts.
- Long messages truncate on one line and stay fully readable through the link; under 520px the call to action hides and under 420px the segments do, leaving pause and close.
- Dismissal can be remembered per device; it is read after hydration, so pass open={false} from a cookie on the server if a flash matters.
Motion
- Messages swap in a fixed 36px window: the new one rises 14px from below with a 2px blur over 340ms on the expo ease-out while the old one leaves upward in 220ms. Going back to an earlier message reverses the direction.
- The link's arrow leans 2px forward on hover; pause and play swap in place with a 160ms scale from 0.6.
- Dismissing collapses the height over 260ms on the in-out quart curve. Reduced motion crossfades messages without travel and fades the bar out without collapsing.
Accessibility
- A labeled region with carousel semantics; each message is a group labeled “2 of 3”.
- While rotating, the message area is aria-live=off so screen readers aren't interrupted every five seconds; when paused or held it becomes polite. Focus inside the bar also pauses it.
- Pause, segments and close are real buttons with names, and the pause control meets the requirement that moving content can be stopped.