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.
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.
Inventory
2 selected
Inventory
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
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.
Columns count themselves based on the card's min-width.
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.
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.
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.
Final composition
The whole grid list with multi-select, lazy images and skeleton loading. About 120 lines.
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
stopPropagationon its own click so toggling doesn't also "open" the card. - Selected state is
aria-pressed. Toggle buttons should bearia-pressed, notaria-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 todisplay: nonebelow-fold cards.