FairGarden Design

Card Grid

A grid of 3–24 peer cards to browse, with an optional header, filter bar, result count and "See all" footer.

import {
  CardGrid,
  CardGridFooter,
  CardGridFooterLink,
  CardGridHeader,
  CardGridItem,
  CardGridList,
} from '@fairgarden-private/design/components/CardGrid'

Each CardGridItem holds one Card. The root is the card-grid container: the columns follow its width, not the viewport, so a grid in a side column reflows as it would on a phone. Without container-query support the list falls back to auto-fit columns at the card minimum. Don't use a card grid for ranked or dated items (use Index Rows or Timeline), don't mix card kinds, and never scroll it sideways.

Editorial and compact

Editorial and compact

kind is required. editorial and block grids run 1 column below 608 px of container, 2 from 608 and 3 from 944; compact grids 1 below 360, 2 below 704 and 4 from 704. lead puts one lead card across the grid, with Index Rows after it. The footer's "See all" is the standalone Link. Faced cards pad by the same container. header renders the §11.8 section header in the header slot: pass SectionHeader props (the grid is then labelled by its heading) or any node, such as a SectionHeader element. CardGridHeader still takes children for a hand-built header.

Programs

Get outside

On the preserve

CardGridKinds.tsx
import {
  Card,
  CardBody,
  CardMeta,
  CardTitle,
  CardTitleLink,
} from '@fairgarden-private/design/components/Card'
import {
  CardGrid,
  CardGridFooter,
  CardGridFooterLink,
  CardGridItem,
  CardGridList,
} from '@fairgarden-private/design/components/CardGrid'
import { SectionHeader } from '@fairgarden-private/design/components/SectionHeader'
import styles from './kinds.module.css'

const programs = [
  { title: 'Spring bird count', meta: 'Program · May', body: 'Volunteers tally migrants along the river trail.' },
  { title: 'Meadow restoration', meta: 'Program · All year', body: 'Three seasons of native seed and patience.' },
  { title: 'Night walk', meta: 'Event · Fridays', body: 'Owls, moths and the smell of wet leaves.' },
]

const species = ['Barred owl', 'Wood thrush', 'Red eft', 'Pawpaw']

/**
 * Editorial grids go 1 → 2 → 3 columns on their container; compact grids
 * 1 → 2 → 4. `header` takes SectionHeader props (the grid is then labelled
 * by the heading) or any node, such as a SectionHeader element.
 */
export function CardGridKinds() {
  return (
    <div className={styles.stack}>
      <CardGrid kind="editorial" header={{ eyebrow: 'Programs', heading: 'Get outside' }}>
        <CardGridList>
          {programs.map((program) => (
            <CardGridItem key={program.title}>
              <Card faced>
                <CardTitle>
                  <CardTitleLink href="#kinds">{program.title}</CardTitleLink>
                </CardTitle>
                <CardMeta>{program.meta}</CardMeta>
                <CardBody>{program.body}</CardBody>
              </Card>
            </CardGridItem>
          ))}
        </CardGridList>
        <CardGridFooter>
          <CardGridFooterLink href="#kinds">See all programs</CardGridFooterLink>
        </CardGridFooter>
      </CardGrid>

      <CardGrid
        kind="compact"
        aria-labelledby="card-grid-species"
        header={<SectionHeader level="module" heading="On the preserve" headingId="card-grid-species" />}
      >
        <CardGridList>
          {species.map((name) => (
            <CardGridItem key={name}>
              <Card>
                <CardTitle>
                  <CardTitleLink href="#kinds">{name}</CardTitleLink>
                </CardTitle>
                <CardMeta>Seen this week</CardMeta>
              </Card>
            </CardGridItem>
          ))}
        </CardGridList>
      </CardGrid>
    </div>
  )
}

Filtered, with a count

Filtered, with a count

CardGridToolbar holds filter chips and sort; it is hidden in print. CardGridCount is a polite live region, so keep it mounted and change its text. When a filter leaves nothing, render an EmptyState with filtered in place of the list, name the filter and offer "Clear Filters".

4 events

CardGridFiltered.tsx
'use client'

import * as React from 'react'
import { Button } from '@fairgarden-private/design/components/Button'
import {
  Card,
  CardMeta,
  CardTitle,
  CardTitleLink,
} from '@fairgarden-private/design/components/Card'
import {
  CardGrid,
  CardGridCount,
  CardGridItem,
  CardGridList,
  CardGridToolbar,
} from '@fairgarden-private/design/components/CardGrid'
import {
  EmptyState,
  EmptyStateAction,
  EmptyStateHeading,
  EmptyStateText,
} from '@fairgarden-private/design/components/EmptyState'
import { Toggle } from '@fairgarden-private/design/components/Toggle'

const events = [
  { title: 'Night walk', format: 'In person' },
  { title: 'Seed swap', format: 'In person' },
  { title: 'Stream ecology talk', format: 'Virtual' },
  { title: 'Trail crew day', format: 'In person' },
]

/** The count is a polite live region; an empty result swaps the list for an EmptyState. */
export function CardGridFiltered() {
  const [virtualOnly, setVirtualOnly] = React.useState(false)
  const [hideVirtual, setHideVirtual] = React.useState(false)
  const shown = events.filter(
    (event) =>
      (!virtualOnly || event.format === 'Virtual') && (!hideVirtual || event.format !== 'Virtual')
  )

  return (
    <CardGrid kind="compact">
      <CardGridToolbar>
        <Toggle variant="chip" pressed={virtualOnly} onPressedChange={setVirtualOnly}>
          Virtual Only
        </Toggle>
        <Toggle variant="chip" pressed={hideVirtual} onPressedChange={setHideVirtual}>
          Hide Virtual
        </Toggle>
      </CardGridToolbar>
      <CardGridCount>
        {shown.length} {shown.length === 1 ? 'event' : 'events'}
      </CardGridCount>
      {shown.length > 0 ? (
        <CardGridList>
          {shown.map((event) => (
            <CardGridItem key={event.title}>
              <Card faced>
                <CardTitle>
                  <CardTitleLink href="#filtered">{event.title}</CardTitleLink>
                </CardTitle>
                <CardMeta>{event.format}</CardMeta>
              </Card>
            </CardGridItem>
          ))}
        </CardGridList>
      ) : (
        <EmptyState filtered>
          <EmptyStateHeading>No events match these filters</EmptyStateHeading>
          <EmptyStateText>“Virtual Only” and “Hide Virtual” leave nothing to show.</EmptyStateText>
          <EmptyStateAction>
            <Button
              onClick={() => {
                setVirtualOnly(false)
                setHideVirtual(false)
              }}
            >
              Clear Filters
            </Button>
          </EmptyStateAction>
        </EmptyState>
      )}
    </CardGrid>
  )
}

Color and print

primary colors the count and footer link; secondary only the footer link's underline and chevron. Bare cards inherit both. Faced cards open their own face, so pass primary and secondary to each faced card too. In print the grid runs 2-up with each row whole, and the filter bar is dropped.

API Reference

CardGrid

The card grid module: an optional header (header, or a composed CardGridHeader holding the §11.8 section header), an optional CardGridToolbar and CardGridCount, the CardGridList of CardGridItems (one Card each), and an optional CardGridFooter with a CardGridFooterLink (“See all ›"). Never mix card kinds in one grid, and never scroll it horizontally (use §12.4).

PropTypeDescription
header
React.ReactNode | SectionHeaderProps | undefined

The module header (§11.8), rendered in the header slot before the children: a SectionHeader props object (the section is then labelled by its heading), or any node, such as a SectionHeader element. Omit it to compose CardGridHeader yourself.

kind
CardGridKind

Required. editorial: articles and programs, 1 → 2 → 3 columns. compact: species, products and team, 1 → 2 → 4 columns. block: the block-edge featured cards (1–3 per page, one card family), 1 → 2 → 3 columns with extra clearance for the heavy edge. lead: one lead card across the grid, followed by §12.11 index rows.

primary
| 'ruby'
| 'olive'
| 'sage'
| 'slate'
| 'sand'
| 'gray'
| 'mauve'
| 'brown'
| 'bronze'
| 'gold'
| 'red'
| 'crimson'
| 'tomato'
| 'pink'
| 'plum'
| 'indigo'
| 'iris'
| 'violet'
| 'purple'
| null
| undefined

Primary Radix scale: the count and footer link text. Never defaulted; omitted, it inherits the scope [D133].

secondary
| 'ruby'
| 'olive'
| 'sage'
| 'slate'
| 'sand'
| 'gray'
| 'mauve'
| 'brown'
| 'bronze'
| 'gold'
| 'red'
| 'crimson'
| 'tomato'
| 'pink'
| 'plum'
| 'indigo'
| 'iris'
| 'violet'
| 'purple'
| 'amber'
| 'blue'
| 'cyan'
| 'grass'
| 'green'
| 'jade'
| 'lime'
| 'mint'
| 'orange'
| 'sky'
| 'teal'
| 'yellow'
| null
| undefined

Secondary Radix scale: the footer link’s underline and chevron (--role-accent, --role-glyph). Never defaulted.

CardGridHeader, CardGridToolbar and CardGridCount

The module header slot: holds the §11.8 SectionHeader (eyebrow + hairline for explore grids; CardGrid’s header prop fills it for you). Sits --size-px-5 above the grid and stays with the first row in print.

The filter and sort bar: filter chips (§9.4) and a sort Select (§10.5), wrapping onto more lines as needed. Hidden in print.

The result count of a filtered grid (“12 events”), a polite live region in --primary12. Keep it mounted and change its text, so the change is announced.

CardGridList and CardGridItem

The grid itself, a ul whose columns follow the card-grid container (auto-fit without container-query support). Rows keep equal heights and print 2-up, each row whole.

One cell: holds one Card, stretched to the row’s height.

The module footer, --size-px-7 below the grid: a CardGridFooterLink or §9.9 pagination.

“See all ›": the standalone Link (§9.3), type-label caps authored in sentence case, with its chevron in --role-glyph. The Link owns every state, so hover follows the central link model.

PropTypeDescription
external
boolean | null | undefined

Adds the arrow-open mark and “(external site)” for assistive technology. Default false.

index
boolean | null | undefined

Adds the screen-only visited ✓ used in long indexes, such as reference and archive lists. Default false [D174, D175].

list
boolean | null | undefined

With kind="nav": a list link (nav-panel, footer, drawer and breadcrumb lists), whose hover is --role-link-hover color only, plus the --ds-stroke-1-5 --role-accent underline where that ink is --primary12. Without it, nav is bare navigation text (bar and utility items, page numbers, toolbar links), whose hover is the --border-size-2 --role-accent underline (§9.3) [D181]. Default false.

muted
boolean | null | undefined

Rests in --role-muted instead of --primary12, as a breadcrumb’s ancestors. Hover takes --role-link-hover only (the underline is added where that ink is --primary12); with kind="nav" it replaces the bar item’s bare-text underline (§9.3, §9.8) [D181]. Default false.

primary
| 'ruby'
| 'olive'
| 'sage'
| 'slate'
| 'sand'
| 'gray'
| 'mauve'
| 'brown'
| 'bronze'
| 'gold'
| 'red'
| 'crimson'
| 'tomato'
| 'pink'
| 'plum'
| 'indigo'
| 'iris'
| 'violet'
| 'purple'
| null
| undefined

Primary Radix scale: the link text and focus ring. Never defaulted; omitted, it inherits the scope [D133].

secondary
| 'ruby'
| 'olive'
| 'sage'
| 'slate'
| 'sand'
| 'gray'
| 'mauve'
| 'brown'
| 'bronze'
| 'gold'
| 'red'
| 'crimson'
| 'tomato'
| 'pink'
| 'plum'
| 'indigo'
| 'iris'
| 'violet'
| 'purple'
| 'amber'
| 'blue'
| 'cyan'
| 'grass'
| 'green'
| 'jade'
| 'lime'
| 'mint'
| 'orange'
| 'sky'
| 'teal'
| 'yellow'
| null
| undefined

Secondary Radix scale: the accent underline. Never defaulted; omitted, it inherits the scope.

className
string | undefined

Extra class names, added after the module’s own.

Additional types

cardGrid
CardGridCountProps

Props for CardGridCount: paragraph props and render.

type CardGridCountProps = useRender.ComponentProps<'p'>
CardGridFooterLinkProps

Props for CardGridFooterLink: Link props except kind, which is always standalone.

type CardGridFooterLinkProps = {
  /** Extra class names, added after the module's own. */
  className?: string;
  /**
   * Primary Radix scale: the link text and focus ring. Never defaulted;
   * omitted, it inherits the scope [D133].
   */
  primary?:
    | 'ruby'
    | 'olive'
    | 'sage'
    | 'slate'
    | 'sand'
    | 'gray'
    | 'mauve'
    | 'brown'
    | 'bronze'
    | 'gold'
    | 'red'
    | 'crimson'
    | 'tomato'
    | 'pink'
    | 'plum'
    | 'indigo'
    | 'iris'
    | 'violet'
    | 'purple'
    | null;
  /**
   * Secondary Radix scale: the accent underline. Never defaulted; omitted,
   * it inherits the scope.
   */
  secondary?:
    | 'ruby'
    | 'olive'
    | 'sage'
    | 'slate'
    | 'sand'
    | 'gray'
    | 'mauve'
    | 'brown'
    | 'bronze'
    | 'gold'
    | 'red'
    | 'crimson'
    | 'tomato'
    | 'pink'
    | 'plum'
    | 'indigo'
    | 'iris'
    | 'violet'
    | 'purple'
    | 'amber'
    | 'blue'
    | 'cyan'
    | 'grass'
    | 'green'
    | 'jade'
    | 'lime'
    | 'mint'
    | 'orange'
    | 'sky'
    | 'teal'
    | 'yellow'
    | null;
  /**
   * With `kind="nav"`: a list link (nav-panel, footer, drawer and breadcrumb
   * lists), whose hover is `--role-link-hover` color only, plus the
   * `--ds-stroke-1-5` `--role-accent` underline where that ink is
   * `--primary12`. Without it, `nav` is bare navigation text (bar and
   * utility items, page numbers, toolbar links), whose hover is the
   * `--border-size-2` `--role-accent` underline (§9.3) [D181]. Default
   * `false`.
   */
  list?: boolean | null;
  /**
   * Adds the screen-only visited ✓ used in long indexes, such as reference
   * and archive lists. Default `false` [D174, D175].
   */
  index?: boolean | null;
  /**
   * Adds the arrow-open mark and "(external site)" for assistive technology.
   * Default `false`.
   */
  external?: boolean | null;
  /**
   * Rests in `--role-muted` instead of `--primary12`, as a breadcrumb's
   * ancestors. Hover takes `--role-link-hover` only (the underline is added
   * where that ink is `--primary12`); with `kind="nav"` it replaces the bar
   * item's bare-text underline (§9.3, §9.8) [D181]. Default `false`.
   */
  muted?: boolean | null;
}
CardGridFooterProps

Props for CardGridFooter: div props and render.

type CardGridFooterProps = useRender.ComponentProps<'div'>
CardGridHeaderProps

Props for CardGridHeader: header props and render.

type CardGridHeaderProps = useRender.ComponentProps<'header'>
CardGridItemProps

Props for CardGridItem: list-item props and render.

type CardGridItemProps = useRender.ComponentProps<'li'>
CardGridKind

The four grid builds (§12.3).

type CardGridKind = 'editorial' | 'compact' | 'block' | 'lead'
CardGridListProps

Props for CardGridList: list props and render.

type CardGridListProps = useRender.ComponentProps<'ul'>
CardGridProps

Props for CardGrid: section props, render, the kind and the color axes.

type CardGridProps = React.ClassAttributes<HTMLElement> &
  React.HTMLAttributes<HTMLElement> &
  __type & {
    kind: CardGridKind;
    primary?:
      | 'ruby'
      | 'olive'
      | 'sage'
      | 'slate'
      | 'sand'
      | 'gray'
      | 'mauve'
      | 'brown'
      | 'bronze'
      | 'gold'
      | 'red'
      | 'crimson'
      | 'tomato'
      | 'pink'
      | 'plum'
      | 'indigo'
      | 'iris'
      | 'violet'
      | 'purple'
      | null;
    secondary?:
      | 'ruby'
      | 'olive'
      | 'sage'
      | 'slate'
      | 'sand'
      | 'gray'
      | 'mauve'
      | 'brown'
      | 'bronze'
      | 'gold'
      | 'red'
      | 'crimson'
      | 'tomato'
      | 'pink'
      | 'plum'
      | 'indigo'
      | 'iris'
      | 'violet'
      | 'purple'
      | 'amber'
      | 'blue'
      | 'cyan'
      | 'grass'
      | 'green'
      | 'jade'
      | 'lime'
      | 'mint'
      | 'orange'
      | 'sky'
      | 'teal'
      | 'yellow'
      | null;
    header?: React.ReactNode | SectionHeaderProps;
  }
CardGridToolbarProps

Props for CardGridToolbar: div props and render.

type CardGridToolbarProps = useRender.ComponentProps<'div'>

Specification: DESIGN-SYSTEM.md §12.3 (card grid) and §5.10.2 (module reflow).