Deck
Browse the catalog

Experience / Application

App Shell

  • page
  • play-fn

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

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.

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.

Default
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.

On Another Section
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.

Tablet
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.

Mobile
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.

Exchange Open
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.

Account Open
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

PropTypeDefaultDescription
accountCardsAccountCard[][ { 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.
accountHandlestring'/alex'Public handle, shown under the name in the account drawer.
accountNamestring'Alex Rivera'The signed-in person, for the account avatar.
accountOpenbooleanfalseOpens the account drawer on mount, for stories.
childrenReactNode—The screen rendered between the bar and the nav.
currentIdstring'/'Which destination is active. Matches a BottomNavItemProps['id'].

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