Deck
Browse the catalog

Build / Molecules

CardIndex

  • molecule
  • play-fn

The index to a CardPile — everyone in it, and a way to bring any one of them to the top.

A pile hands you one card at a time, which is what makes it a pile and not a directory; the cost is that finding a particular person means flipping past everyone in front of them. This is the contents page: it says who is in the pile without opening it, and it takes you straight to one.

The card already on top stays in the list, marked aria-current, rather than being filtered out of it. Two reasons, and both are about the list holding still: a list of "the others" reshuffles every time you advance, so the row you were about to click moves out from under you; and with the current card in place the list is also a position indicator — where you are in the pile, not just what else is in it.

import { CardIndex } from '@meetcard/deck'

Example

<CardIndex label="Everyone from Founders Dinner" value={cards[index].slug} onValueChange={(id) => setIndex(cards.findIndex((c) => c.slug === id))} items={cards.map((c) => ({ id: c.slug, name: c.name, detail: c.title }))} />

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.

Rows: who is in the pile and what they do. The card on top of the pile stays in the list rather than being filtered out of it — so the list holds still while you flip through, and doubles as "where am I".

Default
Source
const Default = () => <div style={{ maxWidth: 420 }}>
    <CardIndex
        items={[
          { id: 'sam', name: 'Sam Ellery', detail: 'CEO at Trail & Co' },
          { id: 'omar', name: 'Omar Whitfield', detail: 'Operating Partner at Halden Group' },
          { id: 'nora', name: 'Nora Lindqvist', detail: 'Creative Director at Field Studio' },
        ]}
        label="Everyone from Founders Dinner"
        value="sam"
        onValueChange={fn()}
        layout="rows" />
</div>;

Picking someone asks for their card, by id.

Picking Someone
Source
const PickingSomeone = () => <div style={{ maxWidth: 420 }}>
    <CardIndex
        items={[
          { id: 'sam', name: 'Sam Ellery', detail: 'CEO at Trail & Co' },
          { id: 'omar', name: 'Omar Whitfield', detail: 'Operating Partner at Halden Group' },
          { id: 'nora', name: 'Nora Lindqvist', detail: 'Creative Director at Field Studio' },
        ]}
        label="Everyone from Founders Dinner"
        value="sam"
        onValueChange={fn()}
        layout="rows" />
</div>;

Strip: faces, scrolled sideways. The index as a way back to a card rather than something to read — so the titles come off and the faces get bigger.

Strip
Source
const Strip = () => <div style={{ maxWidth: 420 }}>
    <CardIndex
        items={[
          { id: 'sam', name: 'Sam Ellery', detail: 'CEO at Trail & Co' },
          { id: 'omar', name: 'Omar Whitfield', detail: 'Operating Partner at Halden Group' },
          { id: 'nora', name: 'Nora Lindqvist', detail: 'Creative Director at Field Studio' },
        ]}
        label="Everyone from Founders Dinner"
        value="sam"
        onValueChange={fn()}
        layout="strip" />
</div>;

A pile of one: the index has nothing to index, and says so by being one row.

One Card
Source
const OneCard = () => <div style={{ maxWidth: 420 }}>
    <CardIndex
        items={items.slice(0, 1)}
        label="Everyone from Founders Dinner"
        value="sam"
        onValueChange={fn()}
        layout="rows" />
</div>;

Wired to a pile. This is the whole point of the component — the list is the way back to a card you have already flipped past.

Selecting Keeps The List Still
Source
const SelectingKeepsTheListStill = () => {
    const [value, setValue] = useState('sam')

    return (
        <div style={{ maxWidth: 420 }}>
            <CardIndex
                items={[
                  { id: 'sam', name: 'Sam Ellery', detail: 'CEO at Trail & Co' },
                  { id: 'omar', name: 'Omar Whitfield', detail: 'Operating Partner at Halden Group' },
                  { id: 'nora', name: 'Nora Lindqvist', detail: 'Creative Director at Field Studio' },
                ]}
                label="Everyone from Founders Dinner"
                layout="rows"
                value={value}
                onValueChange={setValue} />
        </div>
    );
};

A row can carry more than a name: a pill saying which kind of card it is, and one control of its own.

The control is a sibling of the selecting button rather than a child of it — a button inside a button is not a thing a browser will render — so a row can both "show this one" and "edit this one" without the two fighting over the same click.

With Badges And Actions
Source
const WithBadgesAndActions = () => <div style={{ maxWidth: 420 }}>
    <CardIndex
        items={[
          {
            id: 'sam',
            name: 'Sam Ellery',
            detail: 'CEO at Trail & Co',
            badge: <Badge tone="neutral" size="sm">Business</Badge>,
            action: (
              <IconButton label="Edit business card" icon={<PencilIcon />} size="sm" />
            ),
          },
          {
            id: 'omar',
            name: 'Omar Whitfield',
            detail: 'Personal profile',
            badge: <Badge tone="neutral" size="sm">Personal</Badge>,
            action: (
              <IconButton label="Edit personal card" icon={<PencilIcon />} size="sm" />
            ),
          },
        ]}
        label="All cards"
        value="sam"
        onValueChange={fn()}
        layout="rows" />
</div>;

The marker takes the page's own word for it.

A Named Current Marker
Source
const ANamedCurrentMarker = () => <div style={{ maxWidth: 420 }}>
    <CardIndex
        items={[
          { id: 'sam', name: 'Sam Ellery', detail: 'CEO at Trail & Co' },
          { id: 'omar', name: 'Omar Whitfield', detail: 'Operating Partner at Halden Group' },
          { id: 'nora', name: 'Nora Lindqvist', detail: 'Creative Director at Field Studio' },
        ]}
        label="Everyone from Founders Dinner"
        value="omar"
        onValueChange={fn()}
        layout="rows"
        currentLabel="On top" />
</div>;

Props

PropTypeDefaultDescription
itemsrequiredCardIndexItem[]——
currentLabelstring'Showing'What the current row is marked with, in words. Default "Showing" — the card that is on the pile right now.
labelstring'Cards in this pile'Accessible name for the list, e.g. "Everyone from Founders Dinner".
layout'rows' | 'strip' | 'responsive''responsive'—
onValueChange(id: string) => void—Fires with the id of the card that should come to the top.
valuestring—Which card is on top of the pile, by id.

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.