Zum Inhalt springen
UI Kit
Accent

Ankündigungsleiste

Die schmale Zeile über einer Seite, die sagt, was neu ist: wechselnde Meldungen, eine leuchtende Kapsel über der Überschrift, eine Launch-Woche, ein geplanter Hinweis mit Details und ein Sprachvorschlag.

5 VariantsEine von 3 Components in Banners

Eine Zeile, die sagt, was neu ist

Eine Ankündigungsleiste ist die schmale Zeile über einer Seite, die allen sagt, was neu ist, ohne Popup und ohne den Hero zu verdrängen. Die fünf Fassungen hier decken Neuigkeiten, einen Launch, ein geplantes Zeitfenster und ein Sprachangebot ab.

„Wechselnde Meldungen“ lässt mehrere kurze Meldungen auf einer dunklen Leiste wechseln, eine Haarlinie zeigt die verbleibende Zeit, Pfeile und Pause stehen vorn, unter dem Zeiger hält die Leiste an. „Kapsel über der Überschrift“ setzt Etikett und Pfeil über die Hero-Überschrift, ab und zu läuft ein Licht über den Rand. Die „Launch-Woche“ zeigt fünf Tage mit einer Enthüllung je Tag, enthüllte Tage führen zur Meldung, heute leuchtet und eine Linie füllt sich mit der Woche. Der „Geplante Hinweis“ ist eine ruhige, getönte Leiste für ein geplantes Zeitfenster mit Datum und Uhrzeit im Format der Sprache, Details öffnen sich direkt und sagen, was weiter funktioniert. Der „Sprachvorschlag“ bietet die Seite in einer anderen Sprache an, mit echtem Formular zum Wählen, Wechseln oder Bleiben.

Sag je Meldung eine Sache und verlinke dorthin, wo sie erklärt wird. Halte die Leiste ruhig, Bewegung gehört der Seite darunter. Zeitgebundene Hinweise sollten von selbst enden, ein veralteter Hinweis kostet mehr Vertrauen als keiner.

Variants

Wechselnde MeldungenNeu in 1.2

Mehrere kurze Meldungen wechseln sich auf einer dunklen Leiste ab. Eine Haarlinie zeigt die verbleibende Zeit, Pfeile und Pause stehen vorne, unter dem Zeiger hält die Leiste an.

Kapsel über der ÜberschriftNeu in 1.2

Eine Kapsel mit Etikett und Pfeil über der Hero-Überschrift. Ab und zu läuft ein Licht über den Rand, unter dem Zeiger hebt sie sich und leuchtet.

Launch-WocheNeu in 1.2

Fünf Tage, jeden Tag eine Enthüllung. Enthüllte Tage führen zur Meldung, heute leuchtet, die kommenden warten. Eine Linie am unteren Rand füllt sich mit der Woche.

Geplanter HinweisNeu in 1.2

Eine ruhige, getönte Leiste für ein geplantes Zeitfenster, Datum und Uhrzeit im Format der Sprache. Details öffnen sich an Ort und Stelle und sagen, was weiter funktioniert.

SprachvorschlagNeu in 1.2

Bietet die Seite in einer anderen Sprache an, mit echtem Formular: Sprache wählen, wechseln oder bleiben. Beim Ablehnen gleitet die Leiste weg.

Details

Was Ankündigungsleiste mitbringt, aus Registry und Quellcode gelesen.

Kategorie
Banners
Bereich
Marketing
Variants
5
Version
1.0.0 · seit 1.2.0
Datei
components/ui/announcement-bar.tsx
Bringt mit
utilskiticon
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 17 eigene Texte
Studio
Im Code anpassen
In Sammlungen
SaaS-Landingpage

Props

Die Schnittstelle AnnouncementBarProps aus components/ui/announcement-bar.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    AnnouncementBarVariant

    rotatingpilllaunch-weeknoticelocale-suggest

  • messagesoptional
    AnnouncementMessage[]

    rotating: the messages that take turns

  • intervaloptional
    number

    rotating: seconds per message

  • tagoptional
    string

    pill, notice: tag in front

  • textoptional
    string

    pill, notice, launch-week, locale-suggest: the line itself

  • linkoptional
    AnnouncementLink

    pill, notice, launch-week: where the line leads

  • iconoptional
    IconName
  • daysoptional
    AnnouncementDay[]

    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

  • detailsoptional
    string[]

    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

  • dismissibleoptional
    boolean

    Shows a close button. The bar stays closed for this visit.

  • centeroptional
    boolean

    Centers the pill and the bars on the page width (default true)

Eingebaute Texte

Diese Texte schreibt Ankündigungsleiste selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 17 Texte anzeigen
closeEnglisch Close announcementDeutsch Hinweis schließen
prevEnglisch Previous messageDeutsch Vorherige Meldung
nextEnglisch Next messageDeutsch Nächste Meldung
pauseEnglisch Pause messagesDeutsch Meldungen anhalten
playEnglisch Play messagesDeutsch Meldungen abspielen
ofEnglisch {n} of {t}Deutsch {n} von {t}
todayEnglisch TodayDeutsch Heute
doneEnglisch RevealedDeutsch Enthüllt
soonEnglisch SoonDeutsch Bald
detailsEnglisch Show detailsDeutsch Details zeigen
hideEnglisch Hide detailsDeutsch Details ausblenden
windowEnglisch {date}, {from} to {to}Deutsch {date}, {from} bis {to} Uhr
pickEnglisch LanguageDeutsch Sprache
stayEnglisch Stay hereDeutsch Hier bleiben
goEnglisch SwitchDeutsch Wechseln
weekEnglisch Launch weekDeutsch Launch-Woche
messagesEnglisch AnnouncementsDeutsch Ankündigungen

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Setzt aria-controls, aria-current, aria-expanded, aria-label, aria-live, aria-pressed und aria-roledescription, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen region und status.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Jedes Feld hat ein Label, sichtbar oder für Screenreader.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 8 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 514 Zeilen
components/ui/announcement-bar.tsx
'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>
  );
}

Häufige Fragen

Wie installiere ich Ankündigungsleiste?

Mit aiw add @ai-website/announcement-bar. Der Befehl legt die Datei components/ui/announcement-bar.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Ankündigungsleiste?

5 Varianten, Wechselnde Meldungen, Kapsel über der Überschrift, Launch-Woche, Geplanter Hinweis und Sprachvorschlag. Du wählst sie mit der Prop variant, zum Beispiel variant="pill".

Kann ich die Texte von Ankündigungsleiste übersetzen?

Ja. Ankündigungsleiste bringt 17 eigene Texte auf Deutsch und Englisch mit, etwa „Hinweis schließen“, „Vorherige Meldung“ und „Nächste Meldung“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Ankündigungsleiste einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Ankündigungsleiste mit aiw_components und installieren sie mit aiw_add @ai-website/announcement-bar. Nenn im Prompt die Variante, etwa variant="rotating", dann schreibt der Assistent deine Inhalte direkt in die Props.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert