Deck
Browse the catalog

Build / Organisms

BottomNav

  • organism
  • play-fn

The app's primary navigation bar.

Four destinations plus one elevated center action, following the shell's organising principle: destinations are places you go, and Exchange is the thing you do. The center action sits thumb-reachable so the product's highest-frequency moment is one tap from anywhere.

A destination with an href renders as a real link; without one it renders as a button. onSelect fires either way, so a router can intercept the click while the markup stays navigable and shareable. The active item carries aria-current="page", so it is announced rather than only colored.

import { BottomNav } from '@meetcard/deck'

Example

<BottomNav currentId="/connections" items={destinations} centerAction={{ label: 'Exchange', icon: <QrIcon />, onClick: openSheet }} />

Stories

Every preview below is the real component, rendered in this page from the same story Storybook runs. The width control resizes each preview's container; CSS media queries still follow the browser window, so breakpoint behaviour is worth checking in Storybook.

Default
Source
const Default = () => <BottomNav
    items={[
      { id: '/', href: '/', label: 'Dashboard', icon: <Icon d="M4 11l8-7 8 7v8a1 1 0 01-1 1h-5v-6h-4v6H5a1 1 0 01-1-1z" /> },
      { id: '/connections', href: '/connections', label: 'Connections', icon: <Icon d="M4 19v-1a4 4 0 014-4h2a4 4 0 014 4v1M9 10a3 3 0 100-6 3 3 0 000 6zM17 11a3 3 0 100-6" />, badge: 3 },
      { id: '/events', href: '/events', label: 'Events', icon: <Icon d="M4 6h16v14H4zM4 10h16M8 3v4M16 3v4" /> },
      { id: '/me', href: '/me', label: 'Me', icon: <Icon d="M5 20v-1a5 5 0 015-5h4a5 5 0 015 5v1M12 11a4 4 0 100-8 4 4 0 000 8z" /> },
    ]}
    currentId="/" />;

The full PWA shell: header, scrolling content, and the bar with its elevated Exchange action.

In App Shell
Source
const InAppShell = () => {
    const [current, setCurrent] = useState('/connections')
    const [exchanges, setExchanges] = useState(0)

    return (
        <div
            style={{
              display: 'flex',
              flexDirection: 'column',
              height: '520px',
              border: '1px solid var(--deck-color-border-default)',
              borderRadius: 'var(--deck-radius-lg)',
              overflow: 'hidden',
            }}>
            <AppBar
                brand={<Mark style={{ height: 24, width: 24 }} />}
                actions={
                  <IconButton
                    label="Me"
                    variant="ghost"
                    icon={<Avatar name="Ada Lovelace" size="xs" decorative />}
                  />
                } />

            <div style={{ flex: 1, overflow: 'auto' }}>
                <EmptyState
                    title={current}
                    description={
                      exchanges > 0
                        ? `Exchange opened ${exchanges} time${exchanges === 1 ? '' : 's'}.`
                        : 'Tap a destination, or use the elevated Exchange action.'
                    } />
            </div>

            <BottomNav
                items={[
                  { id: '/', href: '/', label: 'Dashboard', icon: <Icon d="M4 11l8-7 8 7v8a1 1 0 01-1 1h-5v-6h-4v6H5a1 1 0 01-1-1z" /> },
                  { id: '/connections', href: '/connections', label: 'Connections', icon: <Icon d="M4 19v-1a4 4 0 014-4h2a4 4 0 014 4v1M9 10a3 3 0 100-6 3 3 0 000 6zM17 11a3 3 0 100-6" />, badge: 3 },
                  { id: '/events', href: '/events', label: 'Events', icon: <Icon d="M4 6h16v14H4zM4 10h16M8 3v4M16 3v4" /> },
                  { id: '/me', href: '/me', label: 'Me', icon: <Icon d="M5 20v-1a5 5 0 015-5h4a5 5 0 015 5v1M12 11a4 4 0 100-8 4 4 0 000 8z" /> },
                ]}
                currentId={current}
                onSelect={setCurrent}
                centerAction={{
                  label: 'Exchange',
                  icon: exchangeIcon,
                  onClick: () => setExchanges((n) => n + 1),
                }} />
        </div>
    );
};

A count on a destination, e.g. pending follow-ups.

With Badge
Source
const WithBadge = () => <BottomNav
    items={[
      { id: '/', href: '/', label: 'Dashboard', icon: <Icon d="M4 11l8-7 8 7v8a1 1 0 01-1 1h-5v-6h-4v6H5a1 1 0 01-1-1z" /> },
      { id: '/connections', href: '/connections', label: 'Connections', icon: <Icon d="M4 19v-1a4 4 0 014-4h2a4 4 0 014 4v1M9 10a3 3 0 100-6 3 3 0 000 6zM17 11a3 3 0 100-6" />, badge: 3 },
      { id: '/events', href: '/events', label: 'Events', icon: <Icon d="M4 6h16v14H4zM4 10h16M8 3v4M16 3v4" /> },
      { id: '/me', href: '/me', label: 'Me', icon: <Icon d="M5 20v-1a5 5 0 015-5h4a5 5 0 015 5v1M12 11a4 4 0 100-8 4 4 0 000 8z" /> },
    ]}
    currentId="/connections" />;

Props

PropTypeDefaultDescription
itemsrequiredBottomNavItemProps[]——
centerAction{ label: string icon: ReactNode onClick: () => void }—The elevated center action. In MeetCard this is Exchange, which opens a sheet rather than navigating — it is an action, not a destination, so it is deliberately not part of items.
currentIdstring—id of the active destination.
labelstring'Primary'—
onSelect(id: string) => void——

Also accepts everything from Omit, so id, data-* and event handlers pass straight through.

Open in Storybookfor controls, the a11y panel and the interaction runner.