Build / Organisms
BottomNav
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
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.
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.
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
| Prop | Type | Default | Description |
|---|---|---|---|
itemsrequired | BottomNavItemProps[] | — | — |
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. |
currentId | string | — | id of the active destination. |
label | string | 'Primary' | — |
onSelect | (id: string) => void | — | — |
Also accepts everything from Omit, so id, data-* and event handlers pass straight through.