Home/ Cookbook/ Lists/ Grid list

Build a grid list — cards for browsing, not rows for processing

By the end of this recipe you'll have a responsive card grid (3-up desktop, 2-up tablet, 1-up phone), with a preview image, title, meta and an action per card — plus multi-select, hover lift and a graceful image-loading state.

Lists 3 primitives · 1 block · 0 patterns ~15 min React · @corelithzw/react

Overview

A responsive card grid: image + title + meta + one action. Multi-select via the card itself. Image-loading and selected states baked in.

A grid list answers a different question to a row list. Rows say "pick the one matching this attribute" — operators scan values. Cards say "show me what's here" — users scan thumbnails. Products, inventory items, gallery assets, vehicles in a yard, units on a building site: cards. Invoices, audit entries, jobs queued, line items: rows.

The opinionated bit is the breakpoint. 3 per row on desktop (≥1024px), 2 on tablet, 1 on phone. Don't try to squeeze 4-up on a 13-inch laptop — text gets too small. Don't go 2-up on phone — the trade-off between image size and readable meta is bad either way; 1-up wins.

Cards are for browsing, rows are for processing. Pick based on the user's intent, not whether you have an image. A photo doesn't make a row a card; the user's task does.

Skip this recipe if the user already knows what they're looking for. Search-then-pick is a row-list problem. Browse-then-decide is a grid problem.

What you'll build

Three states of the same grid: default, multi-select active, single-column phone.

Huchu

Inventory

Rebar 12mm
240 t · $890
Cement OPC
120 t · $1.2k
Bricks clay
18k · $0.18
Sand river
60 m³ · $22
01 Default — 2 per row on phone preview
Huchu

2 selected

Rebar 12mm
Selected
Cement OPC
120 t
Bricks clay
Selected
Sand river
60 m³
02 Multi-select — toolbar appears
Huchu

Inventory

Rebar 12mm
240 t in stock · $890 per ton
Cement OPC
120 t in stock · $1.2k per ton
03 1-up phone — wider image, more meta

Required pieces

A grid, a card, and a top toolbar that appears when something's selected.

@corelithzw/react exports used here: Stack, Card, Checkbox, Button, DataToolbar. Card ships in v0.1-alpha; the optional Grid primitive is on the roadmap — for now use CSS grid directly.

Step-by-step build

01

Lay out the grid with CSS — not JS, not a library

A CSS grid template with auto-fill + minmax handles every breakpoint without a media query. The card's natural minimum width sets the column count. Save the media-query-juggling for layouts that genuinely can't be solved by minmax.

One CSS rule, zero media queries.
Columns count themselves based on the card's min-width.
Step 1 · The grid@corelithzw/react
02

Build the card — image, title, meta, action

Each card is a focusable element with a fixed visual structure: image (4:3) on top, a 2-line title, a single line of meta, optional trailing action. Don't make the image clickable separately from the card — that's two tap targets where there should be one.

Rebar 12mm
240 t · $890/t
Cement OPC
120 t · $1.2k/t
Bricks clay
18k · $0.18
Step 2 · Card
03

Multi-select with a Set, not a boolean-per-item

Lift a Set<string> of selected ids. Don't add an isSelected field to each item — that pulls selection into your data model where it doesn't belong. A Set keeps the selection orthogonal to the array and gives you O(1) toggling.

2 selectedArchiveClear
Rebar 12mm
Cement OPC
Bricks clay
Step 3 · Multi-select
04

Loading skeleton for the image — never a spinner

Images load asynchronously and stagger as they come in. A spinner says "wait"; a skeleton says "this is the shape of what's coming". The skeleton matches the card's image aspect ratio so the layout doesn't jump when the real image arrives.

Rebar 12mm
Step 4 · Image skeleton

Final composition

The whole grid list with multi-select, lazy images and skeleton loading. About 120 lines.

Rebar 12mm
240 t · $890/t
Cement OPC
120 t · $1.2k/t
Bricks clay
18k · $0.18
InventoryGrid.tsx@corelithzw/react

Variations

Three forks. The grid stays, the card shape changes.

Masonry / Pinterest style

Variable card heights, packed top-to-bottom. Use CSS columns or a real masonry lib. Looks great for image galleries; loses ordering predictability.

// CSS columns route — works in 1 line
.grid-list {
  column-count: auto;
  column-width: 220px;
  column-gap: 16px;
}
// Each card needs:
// break-inside: avoid;

Compact card (no image)

For data-dense catalogues — product codes, parts, SKUs. Drop the image entirely and the meta becomes a small table inside the card.

<Card>
  <Stack padding="sm">
    <h3>{item.code}</h3>
    <dl>
      <dt>Stock</dt><dd>{item.stock}</dd>
      <dt>Price</dt><dd>{item.price}</dd>
    </dl>
  </Stack>
</Card>

Hero card on top

First card spans 2 columns — a "featured" item. Use for promoted listings or "latest". Don't use it as a permanent layout; the hero needs editorial input.

// First card spans 2 columns
<Card style={{ gridColumn: 'span 2' }}>
  …
</Card>
// Avoid on phone:
@media (max-width: 480px) {
  .hero { grid-column: span 1; }
}

Accessibility

Grids are easy to get wrong — divs everywhere, no semantics.

  • The grid is a list. role="list" on the container, role="listitem" on each card. Screen readers announce "list with 24 items" — without it, the user has no idea how much they're scrolling through.
  • Cards are buttons. Each card is a real <button>; Space and Enter open, Tab moves between cards in source order (which matches visual order in a CSS grid).
  • Images are decorative. alt="" when the title is right below — otherwise screen readers read the same thing twice. If the image is the content (a gallery), use a real alt.
  • Checkboxes stop propagation. The card itself is clickable; the checkbox inside must stopPropagation on its own click so toggling doesn't also "open" the card.
  • Selected state is aria-pressed. Toggle buttons should be aria-pressed, not aria-selected — the latter is for grid/listbox patterns. Use the right ARIA for the role you chose.
  • Lazy images don't break the focus order. loading="lazy" is fine; the elements exist in the DOM either way. Don't try to display: none below-fold cards.