Announcement bar
The thin line above a page that says what is new: messages that take turns, a lit pill above the headline, a launch week, a planned notice with details and a language suggestion.
One line that says what is new
An announcement bar is the thin line above a page that tells every visitor what is new, without a popup and without pushing the hero away. The five versions here cover news, a launch, a planned window and a language offer.
Rotating messages lets several short messages take turns on a dark bar, a hairline shows the time left, arrows and pause sit at the start and the bar holds still under the pointer. Pill above the headline is a capsule with a tag and an arrow above the hero headline, a light runs along its rim now and then. Launch week shows five days with one reveal a day, revealed days link to their news, today glows and a line fills with the week. Planned notice is a calm tinted bar for a planned window with date and times in the format of the language, details open in place and say what still works. Language suggestion offers the page in another language with a real form, pick, switch or stay.
Say one thing per message and link to where it is explained. Keep the bar calm, motion belongs to the page below. Time based notices should end on their own, an old notice costs more trust than none.
Variants
Details
What Announcement bar brings along, read from the registry and the source.
- Category
- Banners
- Area
- Marketing
- Variants
- 5
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/announcement-bar.tsx
- Brings along
- utilskiticon
- npm packages
- None
- Languages
- English and German, 17 texts of its own
- Studio
- Adjust in code
- In collections
- SaaS landing page
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The AnnouncementBarProps interface from components/ui/announcement-bar.tsx, with the comments from the code.
variantoptionalAnnouncementBarVariantrotatingpilllaunch-weeknoticelocale-suggest
messagesoptionalAnnouncementMessage[]rotating: the messages that take turns
intervaloptionalnumberrotating: seconds per message
tagoptionalstringpill, notice: tag in front
textoptionalstringpill, notice, launch-week, locale-suggest: the line itself
linkoptionalAnnouncementLinkpill, notice, launch-week: where the line leads
iconoptionalIconNamedaysoptionalAnnouncementDay[]launch-week: one entry per day
toneoptional'info' | 'maintenance' | 'success'notice: tone of the notice
windowoptional{ date: string; from: string; to: string }notice: the window, times as HH:MM, written in the format of the language
detailsoptionalstring[]notice: what happens in the window, shown when opened
languagesoptional{ label: string; href: string }[]locale-suggest: the languages to offer, the first one is suggested
dismissibleoptionalbooleanShows a close button. The bar stays closed for this visit.
centeroptionalbooleanCenters the pill and the bars on the page width (default true)
Built-in texts
Announcement bar writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 17 texts
| Key | English | German |
|---|---|---|
| close | English Close announcement | German Hinweis schließen |
| prev | English Previous message | German Vorherige Meldung |
| next | English Next message | German Nächste Meldung |
| pause | English Pause messages | German Meldungen anhalten |
| play | English Play messages | German Meldungen abspielen |
| of | English {n} of {t} | German {n} von {t} |
| today | English Today | German Heute |
| done | English Revealed | German Enthüllt |
| soon | English Soon | German Bald |
| details | English Show details | German Details zeigen |
| hide | English Hide details | German Details ausblenden |
| window | English {date}, {from} to {to} | German {date}, {from} bis {to} Uhr |
| pick | English Language | German Sprache |
| stay | English Stay here | German Hier bleiben |
| go | English Switch | German Wechseln |
| week | English Launch week | German Launch-Woche |
| messages | English Announcements | German Ankündigungen |
Accessibility
Read from the source of the component.
- Sets aria-controls, aria-current, aria-expanded, aria-label, aria-live, aria-pressed and aria-roledescription, so screen readers announce purpose and state.
- Uses the roles region and status.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 8 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 514 lines
'use client';
import { useEffect, useId, useState } from 'react';
import { cn, formatTime, prefersReducedMotion, useLocale, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
/*
* The thin line above a page that says what is new. Five layouts, each for a
* different reason to speak up:
*
* rotating several short messages in turn, with arrows, pause and close
* pill a lit capsule above a headline, the classic "new" pointer
* launch-week a week of daily reveals, today lit, the rest waiting
* notice a planned window (maintenance, closed days) with details to open
* locale-suggest offers the page in the visitor's language, remembered once chosen
*/
export type AnnouncementBarVariant = 'rotating' | 'pill' | 'launch-week' | 'notice' | 'locale-suggest';
export interface AnnouncementLink {
label: string;
href: string;
}
export interface AnnouncementMessage {
text: string;
/** Short tag in front, e.g. New */
tag?: string;
icon?: IconName;
link?: AnnouncementLink;
}
export interface AnnouncementDay {
/** Short day label, e.g. Mon */
day: string;
title: string;
href?: string;
status: 'done' | 'today' | 'next';
}
export interface AnnouncementBarProps {
variant?: AnnouncementBarVariant;
/** rotating: the messages that take turns */
messages?: AnnouncementMessage[];
/** rotating: seconds per message */
interval?: number;
/** pill, notice: tag in front */
tag?: string;
/** pill, notice, launch-week, locale-suggest: the line itself */
text?: string;
/** pill, notice, launch-week: where the line leads */
link?: AnnouncementLink;
icon?: IconName;
/** launch-week: one entry per day */
days?: AnnouncementDay[];
/** notice: tone of the notice */
tone?: 'info' | 'maintenance' | 'success';
/** notice: the window, times as HH:MM, written in the format of the language */
window?: { date: string; from: string; to: string };
/** notice: what happens in the window, shown when opened */
details?: string[];
/** locale-suggest: the languages to offer, the first one is suggested */
languages?: { label: string; href: string }[];
/** Shows a close button. The bar stays closed for this visit. */
dismissible?: boolean;
/** Centers the pill and the bars on the page width (default true) */
center?: boolean;
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
close: 'Close announcement',
prev: 'Previous message',
next: 'Next message',
pause: 'Pause messages',
play: 'Play messages',
of: '{n} of {t}',
today: 'Today',
done: 'Revealed',
soon: 'Soon',
details: 'Show details',
hide: 'Hide details',
window: '{date}, {from} to {to}',
pick: 'Language',
stay: 'Stay here',
go: 'Switch',
week: 'Launch week',
messages: 'Announcements',
},
de: {
close: 'Hinweis schließen',
prev: 'Vorherige Meldung',
next: 'Nächste Meldung',
pause: 'Meldungen anhalten',
play: 'Meldungen abspielen',
of: '{n} von {t}',
today: 'Heute',
done: 'Enthüllt',
soon: 'Bald',
details: 'Details zeigen',
hide: 'Details ausblenden',
window: '{date}, {from} bis {to} Uhr',
pick: 'Sprache',
stay: 'Hier bleiben',
go: 'Wechseln',
week: 'Launch-Woche',
messages: 'Ankündigungen',
},
};
type Texts = (typeof TEXTS)['en'];
type ViewProps = AnnouncementBarProps & { t: Texts; onClose: () => void };
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
function Close({ onClose, t, className }: { onClose: () => void; t: Texts; className?: string }) {
return (
<button
type="button"
onClick={onClose}
aria-label={t.close}
className={cn(
'relative grid size-8 shrink-0 cursor-pointer place-items-center rounded-md text-current opacity-70 transition-[opacity,background-color] hover:bg-[color-mix(in_oklab,currentColor_10%,transparent)] hover:opacity-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-current after:absolute after:-inset-1.5',
className,
)}
>
<Icon name="x" size={16} />
</button>
);
}
function Arrow({ link, className }: { link: AnnouncementLink; className?: string }) {
return (
<a href={link.href} className={cn('group/link inline-flex min-h-11 shrink-0 cursor-pointer items-center gap-1 font-medium underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-current sm:min-h-8', className)}>
{link.label}
<Icon name="arrow-right" size={14} className="transition-transform duration-300 ease-[var(--ease-kit)] group-hover/link:translate-x-0.5 motion-reduce:transition-none" />
</a>
);
}
/* ------------------------------------------------------------------ */
/* rotating */
/* ------------------------------------------------------------------ */
function Rotating(p: ViewProps) {
const { t } = p;
const items = p.messages?.length ? p.messages : [{ text: p.text ?? '', link: p.link }];
const [i, setI] = useState(0);
const [paused, setPaused] = useState(false);
const [hold, setHold] = useState(false);
const [moved, setMoved] = useState(false);
const n = items.length;
const ms = Math.max(3, p.interval ?? 6) * 1000;
useEffect(() => {
if (n < 2 || paused || hold || prefersReducedMotion()) return;
const id = window.setTimeout(() => {
setI((x) => (x + 1) % n);
setMoved(true);
}, ms);
return () => window.clearTimeout(id);
}, [i, n, paused, hold, ms]);
const go = (d: number) => {
setI((x) => (x + d + n) % n);
setMoved(true);
};
const m = items[Math.min(i, n - 1)];
const running = n > 1 && !paused && !hold;
return (
<div
role="region"
aria-roledescription="carousel"
aria-label={t.messages}
onPointerEnter={() => setHold(true)}
onPointerLeave={() => setHold(false)}
onFocus={() => setHold(true)}
onBlur={(e) => !e.currentTarget.contains(e.relatedTarget as Node) && setHold(false)}
className={cn('dark relative overflow-hidden bg-bezel text-foreground', p.className)}
>
{/* the time left for this message, a hairline that fills along the bottom edge */}
{n > 1 && (
<span aria-hidden className="absolute inset-x-0 bottom-0 h-px bg-border">
<span
key={`${i}-${running}`}
className={cn('block h-full origin-left bg-primary motion-reduce:hidden', running ? 'animate-kit-progress' : 'scale-x-0')}
style={{ '--kit-duration': `${ms}ms` } as React.CSSProperties}
/>
</span>
)}
<div className="kit-container flex min-h-11 items-center gap-2 py-1.5 sm:gap-3">
{n > 1 && (
<div className="flex shrink-0 items-center max-sm:hidden">
<button type="button" onClick={() => go(-1)} aria-label={t.prev} className="relative grid size-8 cursor-pointer place-items-center rounded-md text-muted-foreground transition-colors hover:bg-[color-mix(in_oklab,var(--foreground)_8%,transparent)] hover:text-foreground focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-primary">
<Icon name="chevron-left" size={16} />
</button>
<button type="button" onClick={() => setPaused((x) => !x)} aria-label={paused ? t.play : t.pause} aria-pressed={paused} className="relative grid size-8 cursor-pointer place-items-center rounded-md text-muted-foreground transition-colors hover:bg-[color-mix(in_oklab,var(--foreground)_8%,transparent)] hover:text-foreground focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-primary">
<Icon name={paused ? 'play' : 'pause'} size={14} />
</button>
<button type="button" onClick={() => go(1)} aria-label={t.next} className="relative grid size-8 cursor-pointer place-items-center rounded-md text-muted-foreground transition-colors hover:bg-[color-mix(in_oklab,var(--foreground)_8%,transparent)] hover:text-foreground focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-primary">
<Icon name="chevron-right" size={16} />
</button>
</div>
)}
<div aria-live={running ? 'off' : 'polite'} className="relative flex min-w-0 flex-1 items-center justify-center">
<p key={i} className={cn('flex min-w-0 flex-wrap items-center justify-center gap-x-3 gap-y-0.5 text-center text-[13.5px] leading-snug', moved && 'animate-[ab-in_.5s_var(--ease-kit)] motion-reduce:animate-none')}>
<span className="sr-only">{fill(t.of, { n: i + 1, t: n })}. </span>
{m.tag && <span className="inline-flex h-5 items-center rounded-full bg-primary px-2 text-[11px] font-semibold text-primary-foreground">{m.tag}</span>}
{m.icon && <Icon name={m.icon} size={15} className="text-primary" />}
<span className="text-pretty text-foreground">{m.text}</span>
{m.link && <Arrow link={m.link} className="text-[13.5px] text-muted-foreground hover:text-foreground" />}
</p>
</div>
{n > 1 && (
// phones: pause and next, so the turning messages can always be stopped
<div className="-mr-1 flex shrink-0 items-center sm:hidden">
<button type="button" onClick={() => setPaused((x) => !x)} aria-label={paused ? t.play : t.pause} aria-pressed={paused} className="relative grid size-11 cursor-pointer place-items-center rounded-md text-muted-foreground focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-solid focus-visible:outline-primary">
<Icon name={paused ? 'play' : 'pause'} size={14} />
</button>
<button type="button" onClick={() => go(1)} aria-label={t.next} className="relative grid size-11 cursor-pointer place-items-center rounded-md text-muted-foreground focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-solid focus-visible:outline-primary">
<Icon name="chevron-right" size={16} />
</button>
</div>
)}
{n > 1 && (
<span aria-hidden className="shrink-0 text-[12px] tabular-nums text-subtle-foreground max-sm:hidden">
{i + 1}/{n}
</span>
)}
{p.dismissible && <Close onClose={p.onClose} t={t} className="max-sm:size-11" />}
</div>
<style>{'@keyframes ab-in{from{opacity:0;transform:translateY(60%);filter:blur(3px)}}'}</style>
</div>
);
}
/* ------------------------------------------------------------------ */
/* pill */
/* ------------------------------------------------------------------ */
function Pill(p: ViewProps) {
const center = p.center !== false;
const inner = (
<>
{p.tag && (
<span className="relative inline-flex h-6 shrink-0 items-center gap-1.5 rounded-full bg-primary px-2.5 text-[12px] font-semibold text-primary-foreground shadow-[0_0_14px_var(--glow)]">
{p.icon && <Icon name={p.icon} size={13} />}
{p.tag}
</span>
)}
<span className="min-w-0 text-pretty py-1 text-left text-[13.5px] leading-snug text-foreground sm:truncate sm:py-0">{p.text}</span>
{p.link && (
<span className="ml-auto flex shrink-0 items-center gap-1 pr-1 text-[13px] font-medium text-muted-foreground transition-colors group-hover/pill:text-foreground">
<span className="max-sm:sr-only">{p.link.label}</span>
<Icon name="arrow-right" size={14} className="transition-transform duration-300 ease-[var(--ease-kit)] group-hover/pill:translate-x-0.5 motion-reduce:transition-none" />
</span>
)}
</>
);
const cls =
'group/pill kit-rise relative isolate inline-flex min-h-11 max-w-full items-center gap-3 rounded-[1.4rem] sm:rounded-full border border-border bg-[color-mix(in_oklab,var(--card)_80%,transparent)] p-1 pr-3 shadow-[var(--highlight),var(--shadow-sm)] backdrop-blur transition-[border-color,box-shadow,transform] duration-300 ease-[var(--ease-kit)] hover:-translate-y-px hover:border-[color-mix(in_oklab,var(--primary)_40%,var(--border))] hover:shadow-[var(--highlight),0_0_0_4px_var(--glow),var(--shadow-md)] motion-reduce:hover:translate-y-0 sm:min-h-9';
return (
<div className={cn('flex', center && 'justify-center', p.className)}>
<span className="relative inline-flex max-w-full">
{/* a light that circles the rim once in a while */}
<span aria-hidden className="pointer-events-none absolute -inset-px overflow-hidden rounded-full motion-reduce:hidden">
<span className="absolute inset-y-0 -left-1/3 w-1/3 animate-[ab-sweep_5.5s_var(--ease-kit)_1.2s_infinite] bg-[linear-gradient(90deg,transparent,color-mix(in_oklab,var(--primary)_55%,transparent),transparent)] opacity-60" />
</span>
{p.link ? (
<a href={p.link.href} className={cn(cls, 'cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-primary')}>
{inner}
</a>
) : (
<p className={cls}>{inner}</p>
)}
</span>
<style>{'@keyframes ab-sweep{0%{transform:translateX(0)}35%,100%{transform:translateX(420%)}}'}</style>
</div>
);
}
/* ------------------------------------------------------------------ */
/* launch-week */
/* ------------------------------------------------------------------ */
function LaunchWeek(p: ViewProps) {
const { t } = p;
const days = p.days ?? [];
const today = Math.max(0, days.findIndex((d) => d.status === 'today'));
const done = days.filter((d) => d.status === 'done').length + (days.some((d) => d.status === 'today') ? 1 : 0);
const pct = days.length ? Math.round((done / days.length) * 100) : 0;
const current = days[today];
return (
<div className={cn('relative border-b border-border bg-surface', p.className)}>
<div className="kit-container flex min-h-12 flex-wrap items-center gap-x-5 gap-y-2 py-2.5 lg:flex-nowrap">
<p className={cn('flex min-w-0 flex-1 items-center gap-3 text-[13.5px] leading-snug', p.dismissible && 'max-sm:pr-9')}>
<span className="relative flex size-2 shrink-0" aria-hidden>
<span className="absolute inset-0 animate-ping rounded-full bg-primary opacity-60 motion-reduce:animate-none" />
<span className="relative size-2 rounded-full bg-primary" />
</span>
<span className="min-w-0 text-pretty">
<span className="font-semibold text-foreground">{p.tag ?? t.week}</span>
<span className="text-muted-foreground"> · </span>
{current ? (
<>
<span className="font-medium text-primary">{t.today}</span> <span className="text-muted-foreground">{current.title}</span>
</>
) : (
<span className="text-muted-foreground">{p.text}</span>
)}
</span>
</p>
<ol aria-label={p.tag ?? t.week} className="flex w-full items-center gap-1 sm:w-auto">
{days.map((d, i) => {
const state = d.status;
const content = (
<>
<span className="text-[11px] font-medium uppercase tracking-[0.08em]">{d.day}</span>
<span className="sr-only">
{', '}
{d.title}, {state === 'done' ? t.done : state === 'today' ? t.today : t.soon}
</span>
{state === 'done' && <Icon name="check" size={12} strokeWidth={2.2} className="text-primary" />}
</>
);
const cls = cn(
'relative flex h-11 min-w-0 flex-1 items-center justify-center gap-1 rounded-md px-2 transition-[background-color,color,box-shadow] duration-300 sm:h-8 sm:flex-none sm:px-2.5',
state === 'today' && 'bg-primary text-primary-foreground shadow-[0_0_0_3px_var(--glow)]',
state === 'done' && 'bg-card text-foreground ring-1 ring-inset ring-border hover:ring-border-strong',
state === 'next' && 'text-subtle-foreground',
);
return (
<li key={i} className="flex min-w-0 flex-1 sm:flex-none" style={{ '--kit-delay': `${i * 60}ms` } as React.CSSProperties}>
{d.href && state !== 'next' ? (
<a href={d.href} aria-current={state === 'today' ? 'date' : undefined} title={d.title} className={cn(cls, 'kit-rise cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-primary')}>
{content}
</a>
) : (
<span title={d.title} className={cn(cls, 'kit-rise')}>
{content}
</span>
)}
</li>
);
})}
</ol>
{p.link && <Arrow link={p.link} className="text-[13.5px] text-foreground max-lg:hidden" />}
{p.dismissible && <Close onClose={p.onClose} t={t} className="text-muted-foreground max-sm:absolute max-sm:right-2 max-sm:top-1.5 max-sm:size-9" />}
</div>
<span aria-hidden className="absolute inset-x-0 -bottom-px h-0.5">
<span className="block h-full origin-left rounded-r-full bg-primary transition-transform duration-1000 ease-[var(--ease-kit)]" style={{ transform: `scaleX(${pct / 100})` }} />
</span>
</div>
);
}
/* ------------------------------------------------------------------ */
/* notice */
/* ------------------------------------------------------------------ */
const TONE: Record<NonNullable<AnnouncementBarProps['tone']>, { icon: IconName; cls: string; dot: string }> = {
info: { icon: 'info', cls: 'bg-[color-mix(in_oklab,var(--primary)_9%,var(--background))] border-[color-mix(in_oklab,var(--primary)_24%,var(--border))]', dot: 'text-primary' },
maintenance: { icon: 'wrench', cls: 'bg-[color-mix(in_oklab,var(--warning)_14%,var(--background))] border-[color-mix(in_oklab,var(--warning)_40%,var(--border))]', dot: 'text-warning-foreground' },
success: { icon: 'check-circle', cls: 'bg-[color-mix(in_oklab,var(--success)_10%,var(--background))] border-[color-mix(in_oklab,var(--success)_30%,var(--border))]', dot: 'text-success-foreground' },
};
function Notice(p: ViewProps) {
const { t } = p;
const locale = useLocale();
const id = useId();
const [open, setOpen] = useState(false);
const tone = TONE[p.tone ?? 'info'];
const when = p.window ? fill(t.window, { date: p.window.date, from: formatTime(p.window.from, locale), to: formatTime(p.window.to, locale) }) : null;
const hasDetails = Boolean(p.details?.length);
return (
<div role="status" className={cn('border-b', tone.cls, p.className)}>
<div className="kit-container flex items-start gap-3 py-2.5 sm:items-center">
<Icon name={p.icon ?? tone.icon} size={17} className={cn('mt-0.5 shrink-0 sm:mt-0', tone.dot)} />
<div className="flex min-w-0 flex-1 flex-col gap-x-4 gap-y-1 text-[13.5px] leading-snug sm:flex-row sm:flex-wrap sm:items-center">
<p className="min-w-0 text-pretty text-foreground">
{p.tag && <span className="font-semibold">{p.tag} · </span>}
{p.text}
</p>
{when && (
<p className="flex items-center gap-1.5 text-muted-foreground">
<Icon name="clock" size={14} />
<span className="tabular-nums">{when}</span>
</p>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
{hasDetails && (
<button
type="button"
aria-expanded={open}
aria-controls={`${id}-d`}
onClick={() => setOpen((x) => !x)}
className="inline-flex min-h-11 min-w-11 cursor-pointer items-center justify-center gap-1 rounded-md px-2 text-[13px] font-medium text-foreground hover:bg-[color-mix(in_oklab,var(--foreground)_6%,transparent)] focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-primary sm:min-h-8"
>
<span className="max-sm:sr-only">{open ? t.hide : t.details}</span>
<Icon name="chevron-down" size={15} className={cn('transition-transform duration-300 ease-[var(--ease-kit)]', open && 'rotate-180')} />
</button>
)}
{p.dismissible && <Close onClose={p.onClose} t={t} className="max-sm:size-11" />}
</div>
</div>
{hasDetails && (
<div id={`${id}-d`} className={cn('grid transition-[grid-template-rows] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]')}>
<div className={cn('overflow-hidden', !open && 'invisible')}>
<div className="kit-container pb-4">
<ul className="grid gap-2 border-t border-[color-mix(in_oklab,var(--foreground)_10%,transparent)] pt-3 pl-8 text-[13.5px] text-muted-foreground sm:grid-cols-2 lg:grid-cols-3">
{p.details!.map((d, i) => (
<li key={i} className="flex gap-2">
<Icon name="check" size={14} className={cn('mt-0.5 shrink-0', tone.dot)} />
<span className="text-pretty">{d}</span>
</li>
))}
</ul>
{p.link && <Arrow link={p.link} className="ml-8 mt-2 text-[13.5px] text-foreground" />}
</div>
</div>
</div>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* locale-suggest */
/* ------------------------------------------------------------------ */
function LocaleSuggest(p: ViewProps) {
const { t } = p;
const langs = p.languages ?? [];
const id = useId();
const [pick, setPick] = useState(0);
const target = langs[pick];
return (
<div className={cn('kit-rise border-b border-border bg-card', p.className)}>
<form
action={target?.href}
method="get"
onSubmit={(e) => {
if (!target) return;
e.preventDefault();
window.location.href = target.href;
}}
className="kit-container flex flex-col gap-3 py-3 sm:flex-row sm:items-center sm:gap-4"
>
<p className="flex min-w-0 flex-1 items-start gap-2.5 text-[14px] leading-snug text-foreground sm:items-center">
<Icon name={p.icon ?? 'languages'} size={18} className="mt-px shrink-0 text-primary sm:mt-0" />
<span className="text-pretty">{p.text}</span>
</p>
<div className="flex items-center gap-2">
<label htmlFor={id} className="sr-only">
{t.pick}
</label>
<span className="relative min-w-0 flex-1 sm:w-48 sm:flex-none">
<select
id={id}
value={pick}
onChange={(e) => setPick(Number(e.target.value))}
className="h-11 w-full cursor-pointer appearance-none rounded-lg border border-border bg-background pl-3 pr-9 text-base text-foreground transition-[border-color,box-shadow] hover:border-border-strong focus-visible:border-[color-mix(in_oklab,var(--primary)_50%,var(--border))] focus-visible:shadow-[0_0_0_4px_var(--glow)] focus-visible:outline-none sm:h-9 sm:text-[13.5px]"
>
{langs.map((l, i) => (
<option key={i} value={i}>
{l.label}
</option>
))}
</select>
<Icon name="chevrons-up-down" size={15} className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
</span>
<button type="submit" className="inline-flex h-11 shrink-0 cursor-pointer items-center gap-1.5 rounded-lg bg-primary px-4 text-[14px] font-medium text-primary-foreground transition-[filter,transform] hover:brightness-110 active:scale-[0.97] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-primary sm:h-9 sm:text-[13.5px]">
{t.go}
<Icon name="arrow-right" size={14} />
</button>
<button type="button" onClick={p.onClose} className="inline-flex h-11 shrink-0 cursor-pointer items-center rounded-lg px-3 text-[14px] font-medium text-muted-foreground transition-colors hover:bg-[color-mix(in_oklab,var(--foreground)_6%,transparent)] hover:text-foreground focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-primary sm:h-9 sm:text-[13.5px]">
{t.stay}
</button>
</div>
</form>
</div>
);
}
/* ------------------------------------------------------------------ */
const VIEWS: Record<AnnouncementBarVariant, (p: ViewProps) => React.ReactNode> = {
rotating: Rotating,
pill: Pill,
'launch-week': LaunchWeek,
notice: Notice,
'locale-suggest': LocaleSuggest,
};
export function AnnouncementBar({ variant = 'rotating', labels, ...rest }: AnnouncementBarProps) {
const t = useTexts(TEXTS, labels);
const [closed, setClosed] = useState(false);
const [leaving, setLeaving] = useState(false);
const close = () => {
if (prefersReducedMotion()) return setClosed(true);
setLeaving(true);
window.setTimeout(() => setClosed(true), 320);
};
if (closed) return null;
const View = VIEWS[variant] ?? Rotating;
return (
<div className={cn('grid transition-[grid-template-rows,opacity] duration-300 ease-[var(--ease-kit)]', leaving ? 'grid-rows-[0fr] opacity-0' : 'grid-rows-[1fr]')}>
<div className={cn('min-w-0', leaving && 'overflow-hidden')}>
<View {...rest} variant={variant} t={t} onClose={close} />
</div>
</div>
);
}Frequent questions
How do I install Announcement bar?
With aiw add @ai-website/announcement-bar. The command puts components/ui/announcement-bar.tsx into your project, the code is yours from then on. It brings along icon. It needs no further npm packages.
Which variants does Announcement bar have?
5 variants, Rotating messages, Pill above the headline, Launch week, Planned notice and Language suggestion. You pick one with the variant prop, for example variant="pill".
Can I translate the texts of Announcement bar?
Yes. Announcement bar carries 17 texts of its own in English and German, such as “Close announcement”, “Previous message” and “Next message”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Announcement bar?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Announcement bar with aiw_components and install it with aiw_add @ai-website/announcement-bar. Name the variant in your prompt, for example variant="rotating", and the assistant writes your content straight into the props.