Skip to content
UI Kit
Accent color

Bottom nav

The tab bar at the bottom of a phone screen for web apps and mobile sites. Full width on phones, a centered floating bar on tablets, out of the way on desktops. A light glides to the active tab, badges count, a main button opens quick actions and More opens a sheet.

One of 2 components in Sub navigation

5 variants

Variants

With labels

Five tabs with icons and labels. A soft light glides behind the active icon, the icon pops on a tap, counts and dots mark news.

Open in Studio

Center action

A raised main button between the tabs. It turns into a close button and lets three quick actions rise above a soft scrim.

Open in Studio

Floating

A glass pill that floats above full bleed pictures. A lens glides to the active tab, the content shows through the blur.

Open in Studio

Hide on scroll

A slim bar with icons and a dot under the active one. It slides away while you read down and returns as soon as you scroll up. Scroll the phone to try it.

Open in Studio

With More sheet

Four tabs and More. More opens a sheet from the bar with every other place, the light moves to More while it is open. Escape and the scrim close it.

Open in Studio

Details

What Bottom nav brings along, read from the registry and the source.

Area
Layout
Variants
5
Version
1.0.0 · since 1.1.0
File
components/ui/bottom-nav.tsx
Brings along
utilskiticon
npm packages
None
Languages
English and German, 4 texts of its own

Props

The BottomNavProps interface from components/ui/bottom-nav.tsx, with the comments from the code.

  • variantoptional
    BottomNavVariant

    labelscenter-actionfloatinghide-on-scrollwith-sheet

  • itemsrequired
    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

Built-in texts

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

Show all 4 texts
navEnglish MainGerman Hauptnavigation
closeEnglish CloseGerman Schließen
newEnglish newGerman neu
actionsEnglish Quick actionsGerman Schnellaktionen

Accessibility

Read from the source of the component.

  • Bottom nav handles the keys Escape and Home itself.
  • Sets aria-controls, aria-current, aria-expanded and aria-label, so screen readers announce purpose and state.
  • Uses the role dialog.
  • Adds text for screen readers where icons and graphics stand alone.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Rises in briefly the first time it comes into view (kit-rise, useInView).
  • 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. 3 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, 321 lines
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>
    </>
  );
}

Frequent questions

How do I install Bottom nav?

With aiw add @ai-website/bottom-nav. The command puts components/ui/bottom-nav.tsx into your project, the code is yours from then on. It brings along icon. It needs no further npm packages.

Which variants does Bottom nav have?

5 variants, With labels, Center action, Floating, Hide on scroll and With More sheet. You pick one with the variant prop, for example variant="floating".

Can I translate the texts of Bottom nav?

Yes. Bottom nav carries 4 texts of its own in English and German, such as “Main”, “Close” and “new”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Bottom nav?

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

Can I adjust Bottom nav without code?

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

Often searched

Jump to

Copied to the clipboard