Deck
Browse the catalog

Build / Organisms

Sheet

  • organism
  • play-fn

A modal surface that slides up from the bottom.

This is the home of the Exchange action — "Show my card" and "Scan a card" presented together, because they are two halves of one moment.

Built on the native <dialog> element, so focus trapping, Escape to dismiss, inertness of the page behind, and returning focus to whatever opened it all come from the platform rather than hand-rolled listeners.

import { Sheet } from '@meetcard/deck'

Example

<Sheet open={open} onClose={close} title="Exchange"> <Button fullWidth iconStart={<QrIcon />}>Show my card</Button> <Button fullWidth variant="secondary">Scan a card</Button> </Sheet>

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.

The Exchange sheet: showing your card and scanning someone else's, together under one control because they are two halves of the same moment.

Exchange
Source
const Exchange = args => {
    const [open, setOpen] = useState(false)

    return (
        <div style={{ padding: 24 }}>
            <Button onClick={() => setOpen(true)}>Exchange</Button>
            <Sheet
                title="Exchange"
                open={open}
                onClose={() => {
                  setOpen(false)
                  args.onClose()
                }}
                description="Share your card or scan someone else's.">
                <Button fullWidth iconStart={<QrIcon />}>Show my card
                              </Button>
                <Button fullWidth variant="secondary">Scan a card
                              </Button>
            </Sheet>
        </div>
    );
};

Escape closes the sheet — behaviour inherited from native <dialog>.

Closes On Escape
Source
const ClosesOnEscape = () => {
    const [open, setOpen] = useState(true)

    return (
        <div style={{ padding: 24 }}>
            <Sheet title="Exchange" open={open} onClose={() => setOpen(false)}>
                <Button fullWidth>Show my card</Button>
            </Sheet>
            {!open ? <Text>Sheet closed</Text> : null}
        </div>
    );
};

Centered placement, for confirmations rather than thumb-reachable actions.

Centered
Source
const Centered = () => {
    const [open, setOpen] = useState(false)

    return (
        <div style={{ padding: 24 }}>
            <Button onClick={() => setOpen(true)}>Delete card</Button>
            <Sheet
                title="Delete this card?"
                description="This cannot be undone."
                placement="center"
                open={open}
                onClose={() => setOpen(false)}
                footer={
                  <>
                    <Button variant="destructive" size="sm">
                      Delete
                    </Button>
                    <Button variant="ghost" size="sm" onClick={() => setOpen(false)}>
                      Cancel
                    </Button>
                  </>
                } />
        </div>
    );
};

Holding the card up to be scanned — the highest-frequency moment.

Show My Card
Source
const ShowMyCard = () => {
    const [open, setOpen] = useState(false)

    return (
        <div style={{ padding: 24 }}>
            <Button onClick={() => setOpen(true)}>Show my card</Button>
            <Sheet
                title="Scan to connect"
                hideTitle
                open={open}
                onClose={() => setOpen(false)}>
                <QRCode value="https://metcard.io/ben" size="lg">
                    <SampleMatrix />
                </QRCode>
            </Sheet>
        </div>
    );
};

Ports the Lovable prototype's "Share this card" dialog — a QR code carrying the MeetCard mark as its center overlay (QRCode's logo prop), a copyable short link (CopyField), and quick actions to download the code or share to LinkedIn. Deliberately a composition rather than a single rigid component: ShareEvent below reaches for the same three pieces but drops the LinkedIn action, which a fixed-prop component would make far more awkward than just not rendering a Button.

Share Card
Source
const ShareCard = () => {
    const [open, setOpen] = useState(false)

    return (
        <div style={{ padding: 24 }}>
            <Button onClick={() => setOpen(true)}>Share this card</Button>
            <Sheet
                title="Share this card"
                description="Scan the code or share the short link."
                placement="center"
                open={open}
                onClose={() => setOpen(false)}>
                <QRCode value="meetcard.io/ben@meetcard" size="lg">
                    <SampleMatrix />
                </QRCode>
                <CopyField
                    label="Shareable link"
                    value="meetcard.io/ben@meetcard"
                    icon={<LinkIcon />} />
                <div style={{ display: 'flex', gap: 8 }}>
                    <Button variant="secondary" fullWidth iconStart={<DownloadIcon />}>QR
                                    </Button>
                    <Button variant="secondary" fullWidth iconStart={<LinkedInIcon />}>LinkedIn
                                    </Button>
                </div>
            </Sheet>
        </div>
    );
};

Same composition, different context: an event has no LinkedIn share.

Share Event
Source
const ShareEvent = () => {
    const [open, setOpen] = useState(false)

    return (
        <div style={{ padding: 24 }}>
            <Button onClick={() => setOpen(true)}>Share this event</Button>
            <Sheet
                title="Share this event"
                description="Scan the code or share the short link."
                placement="center"
                open={open}
                onClose={() => setOpen(false)}>
                <QRCode value="meetcard.io/events/boulder-startup-week-2026-05-04" size="lg">
                    <SampleMatrix />
                </QRCode>
                <CopyField
                    label="Shareable link"
                    value="meetcard.io/events/boulder-startup-week-2026-05-04"
                    icon={<LinkIcon />} />
                <Button variant="secondary" fullWidth iconStart={<DownloadIcon />}>QR
                              </Button>
            </Sheet>
        </div>
    );
};

Props

PropTypeDefaultDescription
onCloserequired() => void——
openrequiredboolean——
titlerequiredReactNode—Required: the sheet's accessible name.
childrenReactNode——
closeLabelstring'Close'—
descriptionReactNode——
dismissOnBackdropbooleantrueAllow dismissal by backdrop click. Defaults to true.
footerReactNode—Pinned action row at the bottom.
hideTitlebooleanfalseVisually hide the title, keeping it for assistive tech.
placement'sheet' | 'center' | 'side''sheet'sheet slides up from the bottom; center is a centered dialog; side is a full-height drawer on the inline-end edge. side is for account and navigation surfaces, which are lists you run your eye down rather than a decision you make and dismiss. It keeps its edge at every width — a drawer that became a bottom sheet on a phone would put a long list of destinations under the thumb and off the screen.

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.