Zum Inhalt springen
UI Kit
Accent

Banner

Ankündigungsleisten, Hinweise, Countdowns und Aktionen, die sich schließen lassen.

Eine von 2 Components in Banners

6 Variants

Variants

Details

Was Banner mitbringt, aus Registry und Quellcode gelesen.

Kategorie
Banners
Bereich
Marketing
Variants
6
Version
1.0.0 · seit 1.0.0
Datei
components/ui/banner.tsx
Bringt mit
utilskitbuttoniconrevealmotionnumber-ticker
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 14 eigene Texte

Props

Die Schnittstelle BannerProps aus components/ui/banner.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    BannerVariant

    top-barfloatinginline-cardcountdowncookie-strippromo-with-code

  • titleoptional
    React.ReactNode

    Bold first words or the heading of a card

  • messagePflicht
    React.ReactNode
  • tagoptional
    string

    Small label in front, e.g. "New"

  • iconoptional
    IconName
  • actionoptional
    BannerAction
  • secondaryActionoptional
    BannerAction
  • dismissibleoptional
    boolean

    Shows a close button. Defaults to true for top-bar, floating, inline-card and cookie-strip.

  • onDismissoptional
    () => void
  • endsAtoptional
    Date | string | number

    countdown: when the offer ends

  • codeoptional
    string

    promo-with-code: the code to copy

  • valueoptional
    string

    promo-with-code: the big number, e.g. "−20 %"

  • onDecideoptional
    (choice: 'accept' | 'decline') => void

    cookie-strip: called with the choice

  • fixedoptional
    boolean

    floating and cookie-strip are fixed to the viewport. false keeps them in the flow.

Eingebaute Texte

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

Alle 14 Texte anzeigen
closeEnglisch CloseDeutsch Schließen
daysEnglisch DaysDeutsch Tage
hoursEnglisch HoursDeutsch Stunden
minutesEnglisch MinutesDeutsch Minuten
secondsEnglisch SecondsDeutsch Sekunden
endsOnEnglisch Ends on {date}Deutsch Endet am {date}
endedEnglisch This offer has ended.Deutsch Dieses Angebot ist abgelaufen.
copyEnglisch Copy codeDeutsch Code kopieren
copiedEnglisch CopiedDeutsch Kopiert
acceptEnglisch Accept allDeutsch Alle akzeptieren
declineEnglisch Only necessaryDeutsch Nur notwendige
settingsEnglisch SettingsDeutsch Einstellungen
cookiesEnglisch Cookie noticeDeutsch Hinweis zu Cookies
announcementEnglisch AnnouncementDeutsch Ankündigung

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Setzt aria-label und aria-live, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen note, region und timer.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • 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).
  • Bewegt sich mit dem Scrollen, der Fortschritt folgt der Position auf der Seite.
  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 11 Stellen im Code schalten Bewegung dann ab.

Quellcode

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

Datei anzeigen, 470 Zeilen
components/ui/banner.tsx
'use client';

import { createContext, useContext, useEffect, useRef, useState } from 'react';
import { cn, prefersReducedMotion, useInView, useLocale, useTexts, type Locale } from '@/lib/kit';
import { Reveal } from '@/components/ui/reveal';
import { Magnetic, useScrollProgress } from '@/components/ui/motion';
import { NumberTicker } from '@/components/ui/number-ticker';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';

export type BannerVariant = 'top-bar' | 'floating' | 'inline-card' | 'countdown' | 'cookie-strip' | 'promo-with-code';

export interface BannerAction {
  label: string;
  href?: string;
  onClick?: () => void;
}

export interface BannerProps {
  variant?: BannerVariant;
  /** Bold first words or the heading of a card */
  title?: React.ReactNode;
  message: React.ReactNode;
  /** Small label in front, e.g. "New" */
  tag?: string;
  icon?: IconName;
  action?: BannerAction;
  secondaryAction?: BannerAction;
  /** Shows a close button. Defaults to true for top-bar, floating, inline-card and cookie-strip. */
  dismissible?: boolean;
  onDismiss?: () => void;
  /** countdown: when the offer ends */
  endsAt?: Date | string | number;
  /** promo-with-code: the code to copy */
  code?: string;
  /** promo-with-code: the big number, e.g. "−20 %" */
  value?: string;
  /** cookie-strip: called with the choice */
  onDecide?: (choice: 'accept' | 'decline') => void;
  /** floating and cookie-strip are fixed to the viewport. false keeps them in the flow. */
  fixed?: boolean;
  labels?: Labels;
  className?: string;
}

const TEXTS = {
  en: {
    close: 'Close',
    days: 'Days',
    hours: 'Hours',
    minutes: 'Minutes',
    seconds: 'Seconds',
    endsOn: 'Ends on {date}',
    ended: 'This offer has ended.',
    copy: 'Copy code',
    copied: 'Copied',
    accept: 'Accept all',
    decline: 'Only necessary',
    settings: 'Settings',
    cookies: 'Cookie notice',
    announcement: 'Announcement',
  },
  de: {
    close: 'Schließen',
    days: 'Tage',
    hours: 'Stunden',
    minutes: 'Minuten',
    seconds: 'Sekunden',
    endsOn: 'Endet am {date}',
    ended: 'Dieses Angebot ist abgelaufen.',
    copy: 'Code kopieren',
    copied: 'Kopiert',
    accept: 'Alle akzeptieren',
    decline: 'Nur notwendige',
    settings: 'Einstellungen',
    cookies: 'Hinweis zu Cookies',
    announcement: 'Ankündigung',
  },
};

type Labels = Partial<(typeof TEXTS)['en']>;
const LabelsContext = createContext<Labels | undefined>(undefined);
const useT = () => useTexts(TEXTS, useContext(LabelsContext));
const fill = (template: string, values: Record<string, string>) => template.replace(/\{(\w+)\}/g, (_, k: string) => values[k] ?? '');
const LOCALE_TAG: Record<Locale, string> = { en: 'en-US', de: 'de-DE' };

/* Keyframes of the banner: a digit that rolls in, a gentle nudge, a check that pops */
const BANNER_CSS =
  '@keyframes kitfx-bn-roll{from{opacity:0;transform:translateY(-55%)}}@keyframes kitfx-bn-nudge{0%,100%{transform:none}30%{transform:translateY(-3px)}60%{transform:translateY(1px)}}@keyframes kitfx-bn-pop{0%{transform:scale(.5)}60%{transform:scale(1.2)}to{transform:none}}@keyframes kitfx-bn-ring{0%{box-shadow:0 0 0 0 color-mix(in oklab,var(--primary) 35%,transparent)}100%{box-shadow:0 0 0 12px transparent}}';
function BannerStyle() {
  return <style dangerouslySetInnerHTML={{ __html: BANNER_CSS }} />;
}

/** "−20 %" or "$15" counts up in the page language, anything else stays as written */
function Figure({ text }: { text: string }) {
  const locale = useLocale();
  const m = /^(\D*?)(\d[\d.,]*)(\D*)$/.exec(text);
  if (!m) return <>{text}</>;
  const [, pre, raw, post] = m;
  const clean = raw.split(locale === 'de' ? '.' : ',').join('').replace(locale === 'de' ? ',' : '.', '.');
  const value = Number(clean);
  const digits = clean.includes('.') ? clean.length - clean.indexOf('.') - 1 : 0;
  if (!Number.isFinite(value) || new Intl.NumberFormat(LOCALE_TAG[locale], { minimumFractionDigits: digits, maximumFractionDigits: digits }).format(value) !== raw) return <>{text}</>;
  return (
    <>
      {pre}
      <NumberTicker value={value} duration={1200} format={{ minimumFractionDigits: digits, maximumFractionDigits: digits }} />
      {post}
    </>
  );
}

/* ------------------------------------------------------------------ */
/* Shared parts                                                        */
/* ------------------------------------------------------------------ */

function ActionButton({ action, variant = 'primary', size = 'sm', className: extra, icon }: { action: BannerAction; variant?: 'primary' | 'outline' | 'ghost' | 'secondary'; size?: 'sm' | 'md'; className?: string; icon?: boolean }) {
  // Small on the desktop, a full 44 px tap target on phones
  const className = cn(size === 'sm' && 'max-sm:h-11 max-sm:min-h-11 max-sm:px-4 max-sm:text-sm', extra);
  return action.href ? (
    <Button href={action.href} onClick={action.onClick} variant={variant} size={size} icon={icon ? 'arrow-right' : undefined} className={className}>
      {action.label}
    </Button>
  ) : (
    <Button onClick={action.onClick} variant={variant} size={size} icon={icon ? 'arrow-right' : undefined} className={className}>
      {action.label}
    </Button>
  );
}

function Close({ onClick, className }: { onClick: () => void; className?: string }) {
  const t = useT();
  return (
    <button type="button" onClick={onClick} aria-label={t.close} className={cn('grid size-11 shrink-0 place-items-center rounded-lg text-current opacity-70 transition-[opacity,background-color] hover:bg-[color-mix(in_oklch,currentColor_10%,transparent)] hover:opacity-100', className)}>
      <Icon name="x" size={17} />
    </button>
  );
}

/* ------------------------------------------------------------------ */
/* Variants                                                            */
/* ------------------------------------------------------------------ */

function TopBar({ p, close }: { p: BannerProps; close?: () => void }) {
  const t = useT();
  const ref = useScrollProgress<HTMLDivElement>();
  return (
    <div ref={ref} role="region" aria-label={t.announcement} className={cn('dark relative isolate shrink-0 overflow-hidden border-b border-border bg-surface-2 text-foreground', p.className)}>
      <BannerStyle />
      {/* Always a dark band: a light bar above a dark page glares, an inverted one in light mode reads as a header.
          Its light slides along the band while the page scrolls. */}
      <div
        aria-hidden
        data-clip-ok
        className="absolute inset-y-0 -left-1/4 -z-10 w-[150%] bg-[radial-gradient(28%_160%_at_50%_0%,color-mix(in_oklab,var(--primary)_32%,transparent),transparent)] motion-reduce:![transform:none]"
        style={{ transform: 'translate3d(calc((var(--kit-progress, 0.5) - 0.5) * 40%), 0, 0)' }}
      />
      <div className="kit-container flex min-h-11 items-center gap-2 py-1.5 sm:justify-center">
        <p className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2.5 gap-y-1 text-[13px] leading-snug sm:flex-none sm:justify-center">
          {p.tag && <span className="animate-[kitfx-bn-ring_2.4s_ease-out_1s_3] rounded-full bg-[color-mix(in_oklab,var(--primary)_22%,transparent)] px-2 py-0.5 text-[11px] font-semibold text-foreground ring-1 ring-inset ring-[color-mix(in_oklab,var(--primary)_40%,var(--border))] motion-reduce:animate-none">{p.tag}</span>}
          {p.title && <strong className="font-semibold">{p.title}</strong>}
          <span className="text-muted-foreground">{p.message}</span>
          {p.action?.href && (
            <a href={p.action.href} className="group inline-flex items-center gap-1 font-medium underline decoration-border-strong underline-offset-4 hover:decoration-foreground">
              {p.action.label}
              <Icon name="arrow-right" size={13} className="transition-transform group-hover:translate-x-0.5" />
            </a>
          )}
        </p>
        {close && <Close onClick={close} className="-mr-3 sm:absolute sm:right-4 lg:right-6" />}
      </div>
    </div>
  );
}

function useHideOnScroll(active: boolean) {
  const [hidden, setHidden] = useState(false);
  useEffect(() => {
    if (!active || prefersReducedMotion()) return;
    let last = window.scrollY;
    let frame = 0;
    const update = () => {
      frame = 0;
      const y = window.scrollY;
      if (Math.abs(y - last) < 12) return;
      setHidden(y > last && y > 120);
      last = y;
    };
    const onScroll = () => {
      if (!frame) frame = requestAnimationFrame(update);
    };
    window.addEventListener('scroll', onScroll, { passive: true });
    return () => {
      cancelAnimationFrame(frame);
      window.removeEventListener('scroll', onScroll);
    };
  }, [active]);
  return [hidden, setHidden] as const;
}

function Floating({ p, close }: { p: BannerProps; close?: () => void }) {
  const t = useT();
  const fixed = p.fixed !== false;
  const [hidden, setHidden] = useHideOnScroll(fixed);
  return (
    <div
      role="region"
      aria-label={t.announcement}
      onFocus={() => setHidden(false)}
      className={cn(fixed ? 'fixed inset-x-3 bottom-3 z-40 sm:inset-x-auto sm:bottom-6 sm:left-1/2 sm:w-[min(40rem,calc(100%-3rem))] sm:-translate-x-1/2' : 'relative mx-auto max-w-[40rem]', p.className)}
    >
      <BannerStyle />
      <div
        className={cn('transition-[translate,opacity] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', hidden && 'pointer-events-none translate-y-[calc(100%+1.5rem)] opacity-0')}
      >
      <div className="kit-rise flex items-start gap-3.5 rounded-2xl border border-border bg-popover/90 p-3 pl-4 text-foreground shadow-[var(--highlight),var(--shadow-lg)] backdrop-blur-xl sm:items-center" style={{ '--kit-delay': '400ms' } as React.CSSProperties}>
        <span className="mt-0.5 grid size-10 shrink-0 animate-[kitfx-bn-ring_2.4s_ease-out_1.2s_2] place-items-center rounded-xl bg-primary text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.2)] motion-reduce:animate-none sm:mt-0">
          <Icon name={p.icon ?? 'sparkle'} size={19} />
        </span>
        <div className="min-w-0 flex-1 py-0.5">
          {p.title && <p className="text-[14px] font-semibold leading-snug">{p.title}</p>}
          <p className="text-pretty text-[13.5px] leading-snug text-muted-foreground">{p.message}</p>
          {p.action && <ActionButton action={p.action} className="mt-3 sm:hidden" icon />}
        </div>
        {p.action && (
          <Magnetic className="max-sm:hidden">
            <ActionButton action={p.action} icon />
          </Magnetic>
        )}
        {close && <Close onClick={close} className="-my-1 text-muted-foreground" />}
      </div>
      </div>
    </div>
  );
}

function InlineCard({ p, close }: { p: BannerProps; close?: () => void }) {
  const [ref, inView] = useInView<HTMLDivElement>('0px 0px -10% 0px');
  return (
    <Reveal role="note" className={cn('relative overflow-hidden rounded-2xl border border-[color-mix(in_oklab,var(--primary)_28%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_5%,var(--card))] shadow-[var(--highlight),var(--shadow-sm)]', p.className)}>
      <BannerStyle />
      {/* the accent edge draws itself from the top */}
      <span aria-hidden className={cn('absolute inset-y-0 left-0 w-[3px] origin-top bg-primary transition-transform duration-700 ease-[var(--ease-kit)] [transition-delay:250ms] motion-reduce:transition-none', !inView && 'scale-y-0')} />
      <div ref={ref} className="flex flex-col gap-4 p-5 sm:flex-row sm:items-center sm:gap-5 sm:px-6">
        <span className={cn('grid size-10 shrink-0 place-items-center rounded-full bg-[color-mix(in_oklab,var(--primary)_14%,transparent)] text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]', inView && 'animate-[kitfx-bn-ring_2s_ease-out_.6s_2] motion-reduce:animate-none')}>
          <Icon name={p.icon ?? 'info'} size={20} />
        </span>
        <div className="min-w-0 flex-1 pr-8 sm:pr-0">
          {p.title && <p className="text-[15px] font-semibold text-foreground">{p.title}</p>}
          <p className="mt-0.5 text-pretty text-[14px] leading-relaxed text-muted-foreground">{p.message}</p>
        </div>
        {(p.action || p.secondaryAction) && (
          <div className="flex flex-wrap gap-2 sm:shrink-0">
            {/* On phones the filled button leads, so the row starts on the text edge with a visible shape */}
            {p.secondaryAction && <ActionButton action={p.secondaryAction} variant="ghost" className="max-sm:order-last" />}
            {p.action && <ActionButton action={p.action} />}
          </div>
        )}
        {close && <Close onClick={close} className="text-muted-foreground max-sm:absolute max-sm:right-2 max-sm:top-2" />}
      </div>
    </Reveal>
  );
}

function remaining(end: number, now: number) {
  const s = Math.max(0, Math.floor((end - now) / 1000));
  return { d: Math.floor(s / 86400), h: Math.floor((s % 86400) / 3600), m: Math.floor((s % 3600) / 60), s: s % 60, done: s === 0 };
}

function Countdown({ p }: { p: BannerProps }) {
  const t = useT();
  const locale = useLocale();
  const end = p.endsAt === undefined ? Number.NaN : new Date(p.endsAt).getTime();
  // The server never renders the remaining time: it would differ from the browser's and break hydration.
  // Until the page has loaded (and without JavaScript) the digits stay empty and the end date is shown as text.
  const [now, setNow] = useState<number | null>(null);
  useEffect(() => {
    setNow(Date.now());
    const i = window.setInterval(() => setNow(Date.now()), 1000);
    return () => window.clearInterval(i);
  }, []);
  const r = now === null || Number.isNaN(end) ? null : remaining(end, now);
  const date = Number.isNaN(end) ? '' : new Intl.DateTimeFormat(LOCALE_TAG[locale], { weekday: 'long', day: 'numeric', month: 'long', hour: 'numeric', minute: '2-digit' }).format(end);
  const units: [number | null, string][] = [
    [r?.d ?? null, t.days],
    [r?.h ?? null, t.hours],
    [r?.m ?? null, t.minutes],
    [r?.s ?? null, t.seconds],
  ];
  return (
    <section className={cn('kit-grid relative overflow-hidden border-y border-border bg-surface', p.className)}>
      <BannerStyle />
      <div data-clip-ok aria-hidden className="pointer-events-none absolute -left-32 top-1/2 size-96 -translate-y-1/2 animate-kit-pulse rounded-full bg-[var(--glow)] opacity-70 blur-3xl [animation-duration:6s] motion-reduce:animate-none" />
      <div className="kit-container relative grid gap-8 py-10 sm:py-12 lg:grid-cols-[1fr_auto_auto] lg:items-center lg:gap-12">
        <div>
          {p.tag && (
            <span className="mb-3 inline-flex h-6 items-center gap-1.5 rounded-full bg-primary px-2.5 text-[11.5px] font-semibold text-primary-foreground">
              <Icon name="clock" size={12} strokeWidth={2} />
              {p.tag}
            </span>
          )}
          {p.title && <h2 className="text-balance text-[1.5rem] font-semibold leading-tight tracking-[-0.025em] text-foreground sm:text-[1.75rem]">{p.title}</h2>}
          <p className="mt-2 max-w-[34rem] text-pretty text-[15px] leading-relaxed text-muted-foreground">{p.message}</p>
        </div>
        {/* On the desktop the date hangs below the tiles, so the button centres on the tiles alone */}
        <div className="relative">
          <ol suppressHydrationWarning role="timer" aria-label={fill(t.endsOn, { date })} className="grid grid-cols-4 gap-2 sm:gap-3">
            {units.map(([n, label], i) => (
              <Reveal as="li" key={label} delay={i * 80} className="relative flex min-w-0 flex-col items-center overflow-hidden rounded-xl border border-border bg-card px-2 py-3 shadow-[var(--highlight),var(--shadow-sm)] sm:min-w-[4.75rem] sm:px-3">
                {/* a changed value rolls in from above, the tile keeps its size */}
                <span
                  key={n ?? 'x'}
                  className={cn('text-[1.75rem] font-semibold leading-none tracking-[-0.03em] tabular-nums sm:text-[2.125rem]', n === null ? 'text-subtle-foreground' : 'animate-[kitfx-bn-roll_.45s_var(--ease-kit)] text-foreground motion-reduce:animate-none')}
                >
                  {String(n ?? 0).padStart(2, '0')}
                </span>
                <span className="mt-1.5 text-[10.5px] font-medium uppercase tracking-[0.08em] text-subtle-foreground">{label}</span>
              </Reveal>
            ))}
          </ol>
          <p suppressHydrationWarning className="mt-3 text-center text-[12.5px] text-subtle-foreground lg:absolute lg:left-0 lg:top-full lg:whitespace-nowrap lg:text-left">
            {r?.done ? t.ended : fill(t.endsOn, { date })}
          </p>
        </div>
        {p.action && (
          <Magnetic className="max-lg:w-full sm:max-lg:w-auto sm:max-lg:justify-self-start [&>*]:w-full">
            <ActionButton action={p.action} size="md" icon />
          </Magnetic>
        )}
      </div>
    </section>
  );
}

function CookieStrip({ p, close }: { p: BannerProps; close: () => void }) {
  const t = useT();
  const [nudge, setNudge] = useState(false);
  const done = useRef(false);
  useEffect(() => {
    if (prefersReducedMotion()) return;
    const onScroll = () => {
      const max = document.documentElement.scrollHeight - window.innerHeight;
      if (done.current || max <= 0 || window.scrollY / max < 0.4) return;
      done.current = true;
      setNudge(true);
    };
    window.addEventListener('scroll', onScroll, { passive: true });
    return () => window.removeEventListener('scroll', onScroll);
  }, []);
  const decide = (choice: 'accept' | 'decline') => {
    p.onDecide?.(choice);
    close();
  };
  return (
    <div role="region" aria-label={t.cookies} className={cn(p.fixed !== false ? 'fixed inset-x-0 bottom-0 z-40' : 'relative', p.className)}>
      <div className="kit-rise border-t border-border bg-popover/95 text-foreground shadow-[0_-12px_32px_-16px_hsl(var(--shadow-color)/0.25)] backdrop-blur-xl" style={{ '--kit-delay': '300ms' } as React.CSSProperties}>
        <div className="kit-container flex flex-col gap-4 py-4 sm:py-3.5 lg:flex-row lg:items-center lg:gap-8">
          <p className="flex min-w-0 flex-1 items-start gap-3 text-pretty text-[13.5px] leading-relaxed text-muted-foreground lg:items-center">
            <Icon name={p.icon ?? 'cookie'} size={20} className="mt-px shrink-0 animate-[kitfx-bn-nudge_.8s_var(--ease-kit)_1s] text-foreground motion-reduce:animate-none lg:mt-0" />
            <span>
              {p.title && <strong className="mr-1 font-semibold text-foreground">{p.title}</strong>}
              {p.message}
            </span>
          </p>
          <BannerStyle />
          <div className={cn('grid grid-cols-2 gap-2 sm:flex sm:shrink-0 sm:items-center', nudge && 'animate-[kitfx-bn-nudge_.7s_var(--ease-kit)_2] motion-reduce:animate-none')}>
            {p.secondaryAction && <ActionButton action={p.secondaryAction} variant="ghost" className="col-span-2 max-lg:order-last sm:col-span-1" />}
            <Button size="sm" variant="outline" onClick={() => decide('decline')} className="max-sm:h-11 max-sm:min-h-11 max-sm:text-sm">
              {t.decline}
            </Button>
            <Button size="sm" onClick={() => decide('accept')} className="max-sm:h-11 max-sm:min-h-11 max-sm:text-sm">
              {t.accept}
            </Button>
          </div>
        </div>
      </div>
    </div>
  );
}

function PromoWithCode({ p, close }: { p: BannerProps; close?: () => void }) {
  const t = useT();
  const [copied, setCopied] = useState(false);
  const copy = async () => {
    try {
      await navigator.clipboard.writeText(p.code ?? '');
      setCopied(true);
      window.setTimeout(() => setCopied(false), 1800);
    } catch {
      /* clipboard blocked, the code stays selectable */
    }
  };
  return (
    <div className={cn('relative mx-auto max-w-[52rem]', p.className)}>
      <BannerStyle />
      <Reveal effect="scale" className="group relative grid overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-md)] transition-[translate,box-shadow] duration-500 hover:-translate-y-0.5 hover:shadow-[var(--highlight),var(--shadow-lg)] sm:grid-cols-[auto_1fr]">
        {p.value && (
          <div className="relative grid place-items-center bg-primary px-8 py-6 text-primary-foreground sm:py-0">
            <div aria-hidden className="absolute inset-0 bg-[radial-gradient(80%_80%_at_20%_0%,color-mix(in_oklch,var(--primary-foreground)_22%,transparent),transparent)]" />
            <p className="relative text-[2.5rem] font-semibold leading-none tracking-[-0.045em] tabular-nums">
              <Figure text={p.value} />
            </p>
            {/* Perforation between the stub and the ticket */}
            <span data-clip-ok aria-hidden className="absolute -bottom-2.5 left-1/2 size-5 -translate-x-1/2 rounded-full border border-border bg-background sm:-right-2.5 sm:bottom-auto sm:left-auto sm:top-[-0.625rem] sm:translate-x-0" />
            <span data-clip-ok aria-hidden className="absolute -right-2.5 bottom-[-0.625rem] hidden size-5 rounded-full border border-border bg-background sm:block" />
          </div>
        )}
        <div className="flex flex-col gap-5 p-6 sm:p-7 md:flex-row md:items-center md:justify-between md:gap-8">
          <div className="min-w-0 pr-8 md:pr-0">
            {p.title && <p className="text-[16px] font-semibold text-foreground">{p.title}</p>}
            <p className="mt-1 text-pretty text-[14px] leading-relaxed text-muted-foreground">{p.message}</p>
          </div>
          {p.code && (
            <div className="flex shrink-0 items-stretch overflow-hidden rounded-xl border border-dashed border-border-strong bg-surface">
              <span className="kit-shine flex select-all items-center px-4 text-[15px] font-semibold uppercase tracking-[0.14em] text-foreground [background-image:linear-gradient(110deg,transparent_35%,color-mix(in_oklab,var(--primary)_18%,transparent)_50%,transparent_65%)] group-hover:animate-[kit-shine_.9s_var(--ease-kit)]">{p.code}</span>
              <button type="button" onClick={copy} className="flex min-h-11 items-center gap-1.5 border-l border-dashed border-border-strong px-3.5 text-[13px] font-medium text-foreground transition-colors hover:bg-accent" aria-live="polite">
                <Icon name={copied ? 'check' : 'copy'} size={15} className={copied ? 'animate-[kitfx-bn-pop_.4s_var(--ease-kit)] text-success motion-reduce:animate-none' : undefined} />
                {copied ? t.copied : t.copy}
              </button>
            </div>
          )}
        </div>
        {close && <Close onClick={close} className="absolute right-1.5 top-1.5 text-muted-foreground" />}
      </Reveal>
    </div>
  );
}

/**
 * Announcement bars, notices and promotions.
 *   top-bar          a slim bar above the navigation, closable
 *   floating         a card that floats at the bottom of the screen
 *   inline-card      a notice inside the content with an accent edge
 *   countdown        a band with a live countdown to a date
 *   cookie-strip     a slim consent strip at the bottom
 *   promo-with-code  a ticket with a discount code to copy
 */
export function Banner(props: BannerProps) {
  const { variant = 'top-bar', onDismiss } = props;
  const [open, setOpen] = useState(true);
  const [leaving, setLeaving] = useState(false);
  const dismissible = props.dismissible ?? variant !== 'countdown';
  if (!open) return null;
  const close = () => {
    const finish = () => {
      setOpen(false);
      onDismiss?.();
    };
    if (prefersReducedMotion()) return finish();
    setLeaving(true);
    window.setTimeout(finish, 260);
  };
  const c = dismissible ? close : undefined;
  return (
    <LabelsContext.Provider value={props.labels}>
      <div className={cn('contents [&>*]:transition-[opacity,translate,scale] [&>*]:duration-300 [&>*]:ease-[var(--ease-kit)]', leaving && '[&>*]:translate-y-2 [&>*]:scale-[0.99] [&>*]:opacity-0')}>
      {
        {
          'top-bar': <TopBar p={props} close={c} />,
          floating: <Floating p={props} close={c} />,
          'inline-card': <InlineCard p={props} close={c} />,
          countdown: <Countdown p={props} />,
          'cookie-strip': <CookieStrip p={props} close={close} />,
          'promo-with-code': <PromoWithCode p={props} close={c} />,
        }[variant]
      }
      </div>
    </LabelsContext.Provider>
  );
}

Häufige Fragen

Wie installiere ich Banner?

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

Welche Varianten hat Banner?

6 Varianten, Leiste oben, Schwebend, Hinweis im Inhalt, Countdown, Cookie-Leiste und Aktionscode. Du wählst sie mit der Prop variant, zum Beispiel variant="floating".

Kann ich die Texte von Banner übersetzen?

Ja. Banner bringt 14 eigene Texte auf Deutsch und Englisch mit, etwa „Schließen“, „Tage“ und „Stunden“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Banner einbauen?

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

Kann ich Banner ohne Code anpassen?

Ja, im Studio. Alle 6 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden Banner-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert