Skip to content
UI Kit
Accent color

Banners

Announcement bars, notices, countdowns and promotions that step aside when closed.

One of 2 components in Banners

6 variants

Variants

Details

What Banners brings along, read from the registry and the source.

Category
Banners
Variants
6
Version
1.0.0 · since 1.0.0
File
components/ui/banner.tsx
Brings along
utilskitbuttoniconrevealmotionnumber-ticker
npm packages
None
Languages
English and German, 14 texts of its own

Props

The BannerProps interface from components/ui/banner.tsx, with the comments from the code.

  • variantoptional
    BannerVariant

    top-barfloatinginline-cardcountdowncookie-strippromo-with-code

  • titleoptional
    React.ReactNode

    Bold first words or the heading of a card

  • messagerequired
    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.

Built-in texts

Banners writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 14 texts
closeEnglish CloseGerman Schließen
daysEnglish DaysGerman Tage
hoursEnglish HoursGerman Stunden
minutesEnglish MinutesGerman Minuten
secondsEnglish SecondsGerman Sekunden
endsOnEnglish Ends on {date}German Endet am {date}
endedEnglish This offer has ended.German Dieses Angebot ist abgelaufen.
copyEnglish Copy codeGerman Code kopieren
copiedEnglish CopiedGerman Kopiert
acceptEnglish Accept allGerman Alle akzeptieren
declineEnglish Only necessaryGerman Nur notwendige
settingsEnglish SettingsGerman Einstellungen
cookiesEnglish Cookie noticeGerman Hinweis zu Cookies
announcementEnglish AnnouncementGerman Ankündigung

Accessibility

Read from the source of the component.

  • Sets aria-label and aria-live, so screen readers announce purpose and state.
  • Uses the roles note, region and timer.
  • Announces changes through a live region without moving focus.
  • 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).
  • Moves with the scroll, its progress follows the position on the page.
  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 11 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, 470 lines
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>
  );
}

Frequent questions

How do I install Banners?

With aiw add @ai-website/banner. The command puts components/ui/banner.tsx into your project, the code is yours from then on. It brings along button, icon, reveal, motion and number-ticker. It needs no further npm packages.

Which variants does Banners have?

6 variants, Top bar, Floating, Inline notice, Countdown, Cookie strip and Promo code. You pick one with the variant prop, for example variant="floating".

Can I translate the texts of Banners?

Yes. Banners carries 14 texts of its own in English and German, such as “Close”, “Days” and “Hours”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Banners?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Banners with aiw_components and install it with aiw_add @ai-website/banner. Name the variant in your prompt, for example variant="top-bar", and the assistant writes your content straight into the props.

Can I adjust Banners without code?

Yes, in the Studio. All 6 variants are editable there, texts, lists and switches. The Studio writes the matching Banner code to copy.

Often searched

Jump to

Copied to the clipboard