Patterns
PatternsTier 5 · Blocks
Product grid
The responsive grid the cards sit in, with a matching skeleton.
bunx --bun shadcn@latest add @woohl/product-gridReference
Installs this item, its registry dependencies and the token layer.
bunx --bun shadcn@latest add @woohl/product-gridregistry/tier-5-blocks/product-grid.tsx
import type { ComponentProps } from "react";
import { cn } from "@/registry/tier-2-primitives/lib/utils";
import { ProductCardSkeleton } from "@/registry/tier-5-blocks/product-card";
/**
* ProductGrid.
*
* Four across on desktop (the 3/12 span), two on mobile. The gutter is the
* grid gutter from the layout system, not a number chosen per page.
*
* WHY THIS IS A COMPONENT AND NOT A `grid-cols-4` YOU WRITE EACH TIME
* Because the answer at each breakpoint is a system decision, and the fifth
* listing page to be written otherwise ends up 3-across at lg for no reason
* anyone can reconstruct later.
*
* `<ul>`/`<li>` so a screen reader announces "list, 24 items" before the first
* one. A div of cards gives no sense of how much is there.
*/
export type ProductGridProps = Omit<ComponentProps<"ul">, "children"> & {
children: React.ReactNode;
/** Columns at the xl breakpoint. Steps down automatically below it. */
columns?: 2 | 3 | 4 | 5;
/** Announced to screen readers as the list's name. */
label?: string;
};
const COLUMN_CLASS = {
2: "grid-cols-1 xs:grid-cols-2",
3: "grid-cols-2 md:grid-cols-3",
4: "grid-cols-2 md:grid-cols-3 xl:grid-cols-4",
5: "grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5",
} as const;
export function ProductGrid({
children,
columns = 4,
label,
className,
...props
}: ProductGridProps) {
return (
<ul
data-slot="product-grid"
aria-label={label}
className={cn(
"grid list-none gap-4 p-0 md:gap-6",
COLUMN_CLASS[columns],
className,
)}
{...props}
>
{Array.isArray(children) ? (
children.map((child, index) => <li key={index}>{child}</li>)
) : (
<li>{children}</li>
)}
</ul>
);
}
/**
* The grid's loading state — the same shape, filled with skeletons.
*
* `aria-busy` on the region rather than a bare spinner, so assistive tech says
* "busy" about the thing that is loading instead of announcing a decoration.
*/
export function ProductGridSkeleton({
count = 8,
columns = 4,
className,
}: {
count?: number;
columns?: 2 | 3 | 4 | 5;
className?: string;
}) {
return (
<div
aria-busy="true"
aria-live="polite"
aria-label="Loading products"
className={cn("grid gap-4 md:gap-6", COLUMN_CLASS[columns], className)}
>
{Array.from({ length: count }, (_, index) => (
<ProductCardSkeleton key={index} />
))}
</div>
);
}
ProductGridProps
Also accepts everything from Omit<ComponentProps<"ul">, "children">.
| Prop | Type | Default |
|---|---|---|
children* | React.ReactNode | — |
columnsColumns at the xl breakpoint. Steps down automatically below it. | 2 | 3 | 4 | 5 | — |
labelAnnounced to screen readers as the list's name. | string | — |
Registry items
npm packages
None beyond React itself.