Zum Inhalt springen
UI Kit
Accent

Bottom Nav

Die Tab-Leiste unten am Telefon für Web-Apps und mobile Websites. Volle Breite auf dem Telefon, eine schwebende Leiste in der Mitte auf dem Tablet, auf dem Desktop aus dem Weg. Ein Licht gleitet zum aktiven Tab, Badges zählen, ein Hauptknopf öffnet Schnellaktionen und Mehr öffnet ein Sheet.

Eine von 2 Components in Unternavigation

5 Variants

Variants

Mit Beschriftung

Fünf Tabs mit Icon und Beschriftung. Ein weiches Licht gleitet hinter das aktive Icon, das Icon federt beim Tippen, Zahlen und Punkte zeigen Neues.

Im Studio öffnen

Mittlere Aktion

Ein erhabener Hauptknopf zwischen den Tabs. Er wird zum Schließen-Knopf und lässt drei Schnellaktionen über einem weichen Schleier aufsteigen.

Im Studio öffnen

Schwebend

Eine gläserne Pille über randlosen Bildern. Eine Linse gleitet zum aktiven Tab, der Inhalt scheint durch die Unschärfe.

Im Studio öffnen

Weicht beim Scrollen

Eine schmale Leiste mit Icons und einem Punkt unter dem aktiven. Sie gleitet weg, während du nach unten liest, und kommt zurück, sobald du nach oben scrollst. Scrolle im Telefon, um es zu sehen.

Im Studio öffnen

Mit Mehr-Sheet

Vier Tabs und Mehr. Mehr öffnet aus der Leiste ein Sheet mit allen weiteren Bereichen, das Licht wandert dabei zu Mehr. Escape und der Schleier schließen es.

Im Studio öffnen

Details

Was Bottom Nav mitbringt, aus Registry und Quellcode gelesen.

Bereich
Layout
Variants
5
Version
1.0.0 · seit 1.1.0
Datei
components/ui/bottom-nav.tsx
Bringt mit
utilskiticon
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 4 eigene Texte

Props

Die Schnittstelle BottomNavProps aus components/ui/bottom-nav.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    BottomNavVariant

    labelscenter-actionfloatinghide-on-scrollwith-sheet

  • itemsPflicht
    BottomNavItem[]
  • actionoptional
    { label: string; icon?: IconName; actions?: { label: string; icon: IconName; href?: string }[] }

    center-action: the main button and the quick actions it opens

  • moreoptional
    { label: string; title?: string; items: { label: string; icon: IconName; href?: string; description?: string }[] }

    with-sheet: the More tab and what its sheet holds

  • positionoptional
    'fixed' | 'contained'

    fixed (default) or contained in the nearest positioned parent

  • desktopoptional
    boolean

    fixed: show on desktops too. Default false.

  • defaultOpenoptional
    boolean

    Start with the quick actions or the sheet open (Studio preview)

  • onChangeoptional
    (index: number) => void

Eingebaute Texte

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

Alle 4 Texte anzeigen
navEnglisch MainDeutsch Hauptnavigation
closeEnglisch CloseDeutsch Schließen
newEnglisch newDeutsch neu
actionsEnglisch Quick actionsDeutsch Schnellaktionen

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Bottom Nav reagiert selbst auf die Tasten Escape und Pos1.
  • Setzt aria-controls, aria-current, aria-expanded und aria-label, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rolle dialog.
  • 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).
  • 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. 3 Stellen im Code schalten Bewegung dann ab.

Quellcode

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

Datei anzeigen, 321 Zeilen
components/ui/bottom-nav.tsx
'use client';

import { useEffect, useId, useRef, useState } from 'react';
import { cn, prefersReducedMotion, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';

/*
 * The tab bar at the bottom of a phone screen, for web apps and mobile sites.
 *
 *   labels          icons with labels, a soft light glides behind the active icon
 *   center-action   two tabs on each side of a raised main button that opens quick actions
 *   floating        a glass pill above the content, the lens glides to the active tab
 *   hide-on-scroll  a slim bar that slides away while you read down and returns on the way up
 *   with-sheet      four tabs and More, which opens a sheet with every other place
 *
 * position fixed (default) sits at the bottom of the window: full width on
 * phones, a centered floating bar on tablets, hidden from 1024 px where a
 * sidebar or navbar takes over (desktop shows it anyway). position contained
 * sits at the bottom of the nearest positioned parent, for phone mockups.
 *
 * Every tab is at least 44 px tall, the active one carries aria-current, and
 * the light only moves with transform.
 */

export type BottomNavVariant = 'labels' | 'center-action' | 'floating' | 'hide-on-scroll' | 'with-sheet';

export interface BottomNavItem {
  label: string;
  icon: IconName;
  href?: string;
  /** A count, or true for a dot */
  badge?: number | boolean;
  active?: boolean;
}

export interface BottomNavProps {
  variant?: BottomNavVariant;
  items: BottomNavItem[];
  /** center-action: the main button and the quick actions it opens */
  action?: { label: string; icon?: IconName; actions?: { label: string; icon: IconName; href?: string }[] };
  /** with-sheet: the More tab and what its sheet holds */
  more?: { label: string; title?: string; items: { label: string; icon: IconName; href?: string; description?: string }[] };
  /** fixed (default) or contained in the nearest positioned parent */
  position?: 'fixed' | 'contained';
  /** fixed: show on desktops too. Default false. */
  desktop?: boolean;
  /** Start with the quick actions or the sheet open (Studio preview) */
  defaultOpen?: boolean;
  onChange?: (index: number) => void;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

const TEXTS = {
  en: { nav: 'Main', close: 'Close', new: 'new', actions: 'Quick actions' },
  de: { nav: 'Hauptnavigation', close: 'Schließen', new: 'neu', actions: 'Schnellaktionen' },
};
type Texts = (typeof TEXTS)['en'];

const STYLES = `
@keyframes kbn-pop{0%{transform:scale(1)}40%{transform:scale(.86)}100%{transform:scale(1)}}
.kbn-pop{animation:kbn-pop .38s var(--ease-kit)}
@keyframes kbn-up{from{opacity:0;transform:translateY(12px) scale(.96)}}
.kbn-up{animation:kbn-up .42s var(--ease-kit) both;animation-delay:calc(var(--kbn-i,0)*45ms)}
@media (prefers-reduced-motion:reduce){.kbn-pop,.kbn-up{animation:none}}
`;

function BadgeMark({ badge, t }: { badge: BottomNavItem['badge']; t: Texts }) {
  if (!badge) return null;
  if (badge === true) return <span aria-label={t.new} className="absolute -right-0.5 -top-0.5 size-2.5 rounded-full border-2 border-[var(--kbn-bg)] bg-destructive" />;
  return (
    <span className="absolute -right-2.5 -top-1.5 grid h-[18px] min-w-[18px] place-items-center rounded-full border-2 border-[var(--kbn-bg)] bg-destructive px-1 text-[10px] font-semibold tabular-nums leading-none text-destructive-foreground">
      {badge > 99 ? '99+' : badge}
    </span>
  );
}

/** Hides while the page (or a [data-scroll-root] around or beside a contained bar) scrolls down */
function useHideOnScroll(enabled: boolean, root: React.RefObject<HTMLElement | null>) {
  const [hidden, setHidden] = useState(false);
  useEffect(() => {
    if (!enabled || prefersReducedMotion()) return;
    // The page, or a [data-scroll-root] around the bar or next to it
    const box = root.current?.closest<HTMLElement>('[data-scroll-root]') ?? root.current?.parentElement?.querySelector<HTMLElement>(':scope > [data-scroll-root]') ?? null;
    const target: HTMLElement | Window = box ?? window;
    const pos = () => (box ? box.scrollTop : window.scrollY);
    let last = pos();
    let frame = 0;
    const onScroll = () => {
      if (frame) return;
      frame = requestAnimationFrame(() => {
        frame = 0;
        const y = pos();
        const d = y - last;
        if (Math.abs(d) < 6) return;
        setHidden(d > 0 && y > 48);
        last = y;
      });
    };
    target.addEventListener('scroll', onScroll, { passive: true });
    return () => {
      cancelAnimationFrame(frame);
      target.removeEventListener('scroll', onScroll);
    };
  }, [enabled, root]);
  return hidden;
}

/**
 * A tab bar for the bottom of the screen.
 *   <BottomNav items={[{ label: 'Home', icon: 'home', active: true }, { label: 'Search', icon: 'search' }, …]} />
 */
export function BottomNav({ variant = 'labels', items, action, more, position = 'fixed', desktop = false, defaultOpen = false, onChange, labels, className }: BottomNavProps) {
  const t = useTexts(TEXTS, labels);
  const [active, setActive] = useState(() => Math.max(0, items.findIndex((i) => i.active)));
  const [pressed, setPressed] = useState<number | null>(null);
  const [open, setOpen] = useState(defaultOpen);
  const root = useRef<HTMLElement>(null);
  const sheetId = useId();
  const hidden = useHideOnScroll(variant === 'hide-on-scroll', root);
  const contained = position === 'contained';

  useEffect(() => {
    if (!open) return;
    const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false);
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [open]);

  const pick = (i: number, e: React.MouseEvent) => {
    const href = items[i]?.href;
    if (!href || href === '#') e.preventDefault();
    setActive(i);
    setPressed(i);
    setOpen(false);
    onChange?.(i);
  };

  // Where the bar sits
  const place = contained
    ? 'absolute inset-x-0 bottom-0'
    : cn('fixed inset-x-0 bottom-0 sm:bottom-4 sm:left-1/2 sm:right-auto sm:w-[min(32rem,calc(100%-2rem))] sm:-translate-x-1/2', !desktop && 'lg:hidden');
  const floating = variant === 'floating';
  const shell = cn(
    'z-40 [--kbn-bg:var(--card)]',
    place,
    floating ? (contained ? 'bottom-7 px-3' : 'max-sm:bottom-3 max-sm:px-3') : '',
  );
  const bar = cn(
    'relative border-border bg-[color-mix(in_oklab,var(--card)_88%,transparent)] backdrop-blur-xl',
    floating ? 'rounded-[1.75rem] border shadow-[var(--highlight),var(--shadow-lg)]' : cn('border-t', !contained && 'sm:rounded-2xl sm:border sm:shadow-[var(--highlight),var(--shadow-lg)]'),
    // A phone mockup has no safe area, its home bar needs the same room
    contained ? (floating ? '' : 'pb-5') : 'pb-[env(safe-area-inset-bottom)]',
  );

  const n = items.length;
  const tab = (it: BottomNavItem, i: number, opts: { slim?: boolean; lens?: boolean } = {}) => {
    const on = i === active;
    return (
      <li key={it.label} className="min-w-0">
        <a
          href={it.href ?? '#'}
          aria-current={on ? 'page' : undefined}
          onClick={(e) => pick(i, e)}
          className={cn(
            'group/tab relative flex w-full flex-col items-center justify-center gap-1 rounded-xl outline-none transition-colors duration-300 focus-visible:outline-solid focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[var(--ring)]',
            opts.slim ? 'h-14' : 'h-16',
            on ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
          )}
        >
          <span key={pressed === i ? `p${i}` : i} className={cn('relative grid h-8 w-14 place-items-center', pressed === i && 'kbn-pop')}>
            {/* Until the shared light is placed, each active tab lights itself (server, no JS) */}
            <Icon name={it.icon} size={22} strokeWidth={on ? 2 : 1.6} className={cn('relative transition-[color,transform] duration-300', on && !opts.lens && 'text-[color-mix(in_oklab,var(--primary)_82%,var(--foreground))]')} />
            <BadgeMark badge={it.badge} t={t} />
          </span>
          {!opts.slim && <span className={cn('max-w-full truncate px-1 text-[11px] leading-none tracking-[-0.005em]', on ? 'font-semibold' : 'font-medium')}>{it.label}</span>}
          {opts.slim && <span className="sr-only">{it.label}</span>}
          {opts.slim && <span aria-hidden className={cn('absolute bottom-2 size-1 rounded-full bg-primary transition-[opacity,transform] duration-300', on ? 'scale-100 opacity-100' : 'scale-0 opacity-0')} />}
        </a>
      </li>
    );
  };

  /** The light behind the active icon: one element, moved by whole tab widths */
  const light = (count: number, index: number, kind: 'pill' | 'lens') => (
    <span aria-hidden className="pointer-events-none absolute inset-y-0 left-0 flex items-start justify-center transition-transform duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ width: `${100 / count}%`, transform: `translateX(${index * 100}%)` }}>
      {kind === 'pill' ? (
        <span className="mt-2 h-8 w-14 rounded-full bg-[color-mix(in_oklab,var(--primary)_16%,transparent)]" />
      ) : (
        <span className="mt-1 h-14 w-[4.5rem] max-w-[calc(100%-4px)] rounded-[1.25rem] bg-[color-mix(in_oklab,var(--foreground)_8%,transparent)] shadow-[var(--highlight)] ring-1 ring-border" />
      )}
    </span>
  );

  const styles = (
    <style href="kit-bottom-nav" precedence="default">
      {STYLES}
    </style>
  );

  /* ---------- center-action ---------- */
  if (variant === 'center-action') {
    const half = Math.ceil(n / 2);
    const left = items.slice(0, half);
    const right = items.slice(half);
    const cols = n + 1;
    const lightIndex = active < half ? active : active + 1;
    return (
      <>
        {styles}
        {open && <div aria-hidden onClick={() => setOpen(false)} className={cn('z-30 bg-scrim kit-rise [--kit-delay:0ms]', contained ? 'absolute inset-0' : 'fixed inset-0')} />}
        <nav ref={root} aria-label={t.nav} className={cn(shell, className)}>
          {open && action?.actions?.length ? (
            <ul id={sheetId} aria-label={t.actions} className="absolute inset-x-0 bottom-full mb-3 flex flex-col items-center gap-2 px-4">
              {action.actions.map((a, i) => (
                <li key={a.label} className="kbn-up" style={{ '--kbn-i': action.actions!.length - 1 - i } as React.CSSProperties}>
                  <a href={a.href ?? '#'} onClick={(e) => { if (!a.href || a.href === '#') e.preventDefault(); setOpen(false); }} className="flex h-12 items-center gap-3 rounded-full border border-border bg-popover pl-2 pr-5 text-[14px] font-medium text-foreground shadow-[var(--highlight),var(--shadow-md)] outline-none transition-transform duration-200 hover:-translate-y-0.5 focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-[var(--ring)]">
                    <span className="grid size-9 place-items-center rounded-full bg-[color-mix(in_oklab,var(--primary)_14%,transparent)] text-[color-mix(in_oklab,var(--primary)_82%,var(--foreground))]">
                      <Icon name={a.icon} size={18} />
                    </span>
                    {a.label}
                  </a>
                </li>
              ))}
            </ul>
          ) : null}
          <div className={bar}>
            <ul className="relative grid px-1" style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}>
              {light(cols, lightIndex, 'pill')}
              {left.map((it, i) => tab(it, i))}
              <li className="relative flex justify-center">
                <button
                  type="button"
                  aria-expanded={action?.actions?.length ? open : undefined}
                  aria-controls={action?.actions?.length ? sheetId : undefined}
                  onClick={() => setOpen((o) => !o)}
                  className="group/fab absolute -top-5 grid size-14 place-items-center rounded-full bg-primary text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.22),0_8px_24px_-6px_color-mix(in_oklab,var(--primary)_60%,transparent)] outline-none ring-4 ring-[var(--kbn-bg)] transition-transform duration-300 ease-[var(--ease-kit)] hover:scale-105 focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--ring)] active:scale-95"
                >
                  <Icon name={action?.icon ?? 'plus'} size={24} strokeWidth={2} className={cn('transition-transform duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', open && 'rotate-[135deg]')} />
                  <span className="sr-only">{open ? t.close : (action?.label ?? '')}</span>
                </button>
                <span aria-hidden className="mt-auto pb-2.5 text-[11px] font-medium leading-none text-muted-foreground">{action?.label}</span>
              </li>
              {right.map((it, i) => tab(it, i + half))}
            </ul>
          </div>
        </nav>
      </>
    );
  }

  /* ---------- with-sheet ---------- */
  if (variant === 'with-sheet' && more) {
    const cols = n + 1;
    const moreOn = open;
    return (
      <>
        {styles}
        {open && <div aria-hidden onClick={() => setOpen(false)} className={cn('z-30 bg-scrim kit-rise [--kit-delay:0ms]', contained ? 'absolute inset-0' : 'fixed inset-0')} />}
        <nav ref={root} aria-label={t.nav} className={cn(shell, className)}>
          {open && (
            <div id={sheetId} role="dialog" aria-label={more.title ?? more.label} className={cn('absolute inset-x-0 bottom-full mb-0 rounded-t-3xl border border-b-0 border-border bg-popover px-4 pb-4 pt-2 shadow-[var(--shadow-lg)] kbn-up', !contained && 'sm:mb-2 sm:rounded-3xl sm:border-b')}>
              <span aria-hidden className="mx-auto mb-3 block h-1 w-9 rounded-full bg-border-strong" />
              <div className="mb-3 flex items-center justify-between px-1">
                <p className="text-[15px] font-semibold text-foreground">{more.title ?? more.label}</p>
                <button type="button" onClick={() => setOpen(false)} aria-label={t.close} className="-mr-2 grid size-11 place-items-center rounded-full text-muted-foreground hover:bg-accent hover:text-foreground">
                  <Icon name="x" size={18} />
                </button>
              </div>
              <ul className="grid grid-cols-2 gap-2">
                {more.items.map((m, i) => (
                  <li key={m.label} className="kbn-up" style={{ '--kbn-i': i } as React.CSSProperties}>
                    <a href={m.href ?? '#'} onClick={(e) => { if (!m.href || m.href === '#') e.preventDefault(); setOpen(false); }} className="flex min-h-16 flex-col justify-center gap-1 rounded-2xl border border-border bg-surface p-3 outline-none transition-colors hover:bg-accent focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-[var(--ring)]">
                      <Icon name={m.icon} size={20} className="text-[color-mix(in_oklab,var(--primary)_82%,var(--foreground))]" />
                      <span className="text-[13px] font-medium text-foreground">{m.label}</span>
                      {m.description && <span className="text-[11px] leading-snug text-muted-foreground">{m.description}</span>}
                    </a>
                  </li>
                ))}
              </ul>
            </div>
          )}
          <div className={cn(bar, open && 'rounded-t-none')}>
            <ul className="relative grid px-1" style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}>
              {light(cols, moreOn ? n : active, 'pill')}
              {items.map((it, i) => tab(it, i))}
              <li className="min-w-0">
                <button type="button" aria-expanded={open} aria-controls={sheetId} onClick={() => setOpen((o) => !o)} className={cn('relative flex h-16 w-full flex-col items-center justify-center gap-1 rounded-xl outline-none transition-colors focus-visible:outline-solid focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[var(--ring)]', open ? 'text-foreground' : 'text-muted-foreground hover:text-foreground')}>
                  <span className="relative grid h-8 w-14 place-items-center">
                    <Icon name={open ? 'x' : 'grid'} size={22} strokeWidth={open ? 2 : 1.6} className={cn('transition-transform duration-300', open && 'rotate-90 text-[color-mix(in_oklab,var(--primary)_82%,var(--foreground))]')} />
                  </span>
                  <span className={cn('text-[11px] leading-none', open ? 'font-semibold' : 'font-medium')}>{more.label}</span>
                </button>
              </li>
            </ul>
          </div>
        </nav>
      </>
    );
  }

  /* ---------- labels, floating, hide-on-scroll ---------- */
  const slim = variant === 'hide-on-scroll';
  return (
    <>
      {styles}
      <nav
        ref={root}
        aria-label={t.nav}
        className={cn(shell, 'transition-transform duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', hidden && 'translate-y-[calc(100%+1.5rem)] sm:translate-y-[calc(100%+2rem)]', className)}
      >
        <div className={bar}>
          <ul className={cn('relative grid', floating ? 'px-1.5' : 'px-1')} style={{ gridTemplateColumns: `repeat(${n}, minmax(0, 1fr))` }}>
            {!slim && light(n, active, floating ? 'lens' : 'pill')}
            {items.map((it, i) => tab(it, i, { slim, lens: floating }))}
          </ul>
        </div>
      </nav>
    </>
  );
}

Häufige Fragen

Wie installiere ich Bottom Nav?

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

Welche Varianten hat Bottom Nav?

5 Varianten, Mit Beschriftung, Mittlere Aktion, Schwebend, Weicht beim Scrollen und Mit Mehr-Sheet. Du wählst sie mit der Prop variant, zum Beispiel variant="floating".

Kann ich die Texte von Bottom Nav übersetzen?

Ja. Bottom Nav bringt 4 eigene Texte auf Deutsch und Englisch mit, etwa „Hauptnavigation“, „Schließen“ und „neu“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Bottom Nav einbauen?

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

Kann ich Bottom Nav ohne Code anpassen?

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

Oft gesucht

Direkt zu

In die Zwischenablage kopiert