Experience / Application
App Shell
The persistent shell every authenticated screen renders inside.
Composes AppBar at the top with one of two navigations beneath it: the
SideNav rail from md (768px) up, the BottomNav bar below. Only one is
ever rendered visibly — the other is display: none and so is out of the
accessibility tree too — and the swap is pure CSS, so there is no viewport
JS to disagree with the server about.
The two navigations are not the same list at two sizes. The rail carries
"My cards" and no Exchange; the bar carries Exchange and no "My cards".
See useDestinations for why. That means the shell's navigation changes
content, not just arrangement, at the breakpoint — unusual, and worth
knowing before adding a destination to one and not the other.
A consequence worth stating plainly: Exchange has no desktop entry
point. The center action is the only way into the sheet, and it exists
only in the bar. That mirrors the product's own model, where exchanging is
an in-person moment, and the desktop path is expected to come from page
content — Dashboard's "Share via QR" — rather than from the frame. If that
turns out to be wrong, an AppBar action is the place to fix it.
The layout is a grid in both modes rather than a fixed rail with a hard-coded top offset: the bar spans row one, so the rail begins exactly where the bar ends whatever the bar's height turns out to be with a notch, a longer title, or a second line.
Not exported from @meetcard/deck — this is a composed screen, built from the components below rather than shipped as one.
Example
<AppShell currentId="/connections"> <Connections /> </AppShell>Stories
Desktop. Storybook's browser runner renders at 1200px unless a story pins
a viewport, so this is above the md breakpoint and shows the rail.
Source
const Default = () => <AppShell>(<Stack gap={16} style={{ padding: 'var(--deck-space-20)' }}>
<Heading level={2} size="sm">
Recent connections
</Heading>
{['Ben Ackles', 'Priya Shah', 'Marcus Liu'].map((name) => (
<Card key={name} surface="subtle" elevation="none" style={{ padding: 16 }}>
<Text weight="medium">{name}</Text>
</Card>
))}
</Stack>)</AppShell>;A different destination current. The bar does not name it — the page does, in its own heading — so the only thing that moves is the rail's fill.
Source
const OnAnotherSection = () => <AppShell currentId="/connections">(<Stack gap={16} style={{ padding: 'var(--deck-space-20)' }}>
<Heading level={2} size="sm">
Recent connections
</Heading>
{['Ben Ackles', 'Priya Shah', 'Marcus Liu'].map((name) => (
<Card key={name} surface="subtle" elevation="none" style={{ padding: 16 }}>
<Text weight="medium">{name}</Text>
</Card>
))}
</Stack>)</AppShell>;The breakpoint's own value. 768px is the first width that gets the rail, so this is the edge worth spending a story on.
Source
const Tablet = () => <AppShell>(<Stack gap={16} style={{ padding: 'var(--deck-space-20)' }}>
<Heading level={2} size="sm">
Recent connections
</Heading>
{['Ben Ackles', 'Priya Shah', 'Marcus Liu'].map((name) => (
<Card key={name} surface="subtle" elevation="none" style={{ padding: 16 }}>
<Text weight="medium">{name}</Text>
</Card>
))}
</Stack>)</AppShell>;Phone. The bar replaces the rail, Exchange appears as the center action, and "My cards" gives up its slot to make room.
Source
const Mobile = () => <AppShell>(<Stack gap={16} style={{ padding: 'var(--deck-space-20)' }}>
<Heading level={2} size="sm">
Recent connections
</Heading>
{['Ben Ackles', 'Priya Shah', 'Marcus Liu'].map((name) => (
<Card key={name} surface="subtle" elevation="none" style={{ padding: 16 }}>
<Text weight="medium">{name}</Text>
</Card>
))}
</Stack>)</AppShell>;Exchange opens a sheet rather than navigating. Pinned to a phone because the center action that opens it exists only in the bar.
Source
const ExchangeOpen = () => <AppShell>(<Stack gap={16} style={{ padding: 'var(--deck-space-20)' }}>
<Heading level={2} size="sm">
Recent connections
</Heading>
{['Ben Ackles', 'Priya Shah', 'Marcus Liu'].map((name) => (
<Card key={name} surface="subtle" elevation="none" style={{ padding: 16 }}>
<Text weight="medium">{name}</Text>
</Card>
))}
</Stack>)</AppShell>;The avatar in the bar opens the account drawer. Pinned to a phone because this is the only route to your own cards at that width — the bar gives the "My cards" slot up to Exchange, so the drawer carries it instead.
Source
const AccountOpen = () => <AppShell>(<Stack gap={16} style={{ padding: 'var(--deck-space-20)' }}>
<Heading level={2} size="sm">
Recent connections
</Heading>
{['Ben Ackles', 'Priya Shah', 'Marcus Liu'].map((name) => (
<Card key={name} surface="subtle" elevation="none" style={{ padding: 16 }}>
<Text weight="medium">{name}</Text>
</Card>
))}
</Stack>)</AppShell>;Props
| Prop | Type | Default | Description |
|---|---|---|---|
accountCards | AccountCard[] | [
{
id: 'personal',
label: 'Personal card',
link: 'meetcard.io/alex',
icon: icon(IdCard),
},
{
id: 'business',
label: 'Business card',
link: 'meetcard.io/alex@northwind',
icon: icon(Briefcase),
isDefault: true,
},
] | The cards the account drawer offers. |
accountHandle | string | '/alex' | Public handle, shown under the name in the account drawer. |
accountName | string | 'Alex Rivera' | The signed-in person, for the account avatar. |
accountOpen | boolean | false | Opens the account drawer on mount, for stories. |
children | ReactNode | — | The screen rendered between the bar and the nav. |
currentId | string | '/' | Which destination is active. Matches a BottomNavItemProps['id']. |