# React product grid with filters

_Box Kite 2.2.4 · a markdown copy of https://www.box-kite.dev/blocks/react-product-grid/_

A React product grid with category and colour filters, sorting, an announced result count and cards that are one link each, light and dark.

## Install

One command copies the files into your project, as code you then own. It needs a `components.json`, and [the shadcn registry page](https://www.box-kite.dev/registry.md) has the one-time setup.

```shell
npx shadcn@latest add https://www.box-kite.dev/r/product-grid.json
```

[Open in v0](https://v0.dev/chat/api/open?url=https%3A%2F%2Fwww.box-kite.dev%2Fr%2Fproduct-grid.json)

[The item JSON](https://www.box-kite.dev/r/product-grid.json)

2 files · installs @box-kite/react, store-design

## Light and dark

The same component twice, each half in a theme of its own — nothing in it is written for either. [Open it full screen](https://www.box-kite.dev/blocks/react-product-grid/demo.md) to see it in yours.

## Source

The files the command writes, exactly as the preview above runs them. Every style is a prop, so there is no stylesheet to copy with them.

**box-kite/store/product-grid.tsx**

```jsx
'use client';
import Box from '@box-kite/react';
import Button from '@box-kite/react/components/button';
import Checkbox from '@box-kite/react/components/checkbox';
import Dropdown from '@box-kite/react/components/dropdown';
import Flex from '@box-kite/react/components/flex';
import Grid from '@box-kite/react/components/grid';
import { H2, P, Section, Span } from '@box-kite/react/components/semantics';
import { type ReactNode, useId, useMemo, useState } from 'react';
import ProductCard from './product-card';
import { categories, finishes, type Product, products as catalogue } from './store-data';
import { heading } from './store-design';

type Sort = 'featured' | 'price-asc' | 'price-desc' | 'name';

const sorts: { value: Sort; label: string }[] = [
  { value: 'featured', label: 'Featured' },
  { value: 'price-asc', label: 'Price, low to high' },
  { value: 'price-desc', label: 'Price, high to low' },
  { value: 'name', label: 'Name' },
];

const order: Record<Sort, (a: Product, b: Product) => number> = {
  featured: () => 0,
  'price-asc': (a, b) => a.price - b.price,
  'price-desc': (a, b) => b.price - a.price,
  name: (a, b) => a.name.localeCompare(b.name),
};

export interface ProductGridProps {
  products?: readonly Product[];
  title?: string;
}

/**
 * A product grid with filters: categories and finishes as checkboxes, a sort, and a count that is announced
 * as it changes. Filtering is a `useMemo` over the list it is given — hand it the page your API returned.
 */
export default function ProductGrid({ products = catalogue, title = 'Shop the collection' }: ProductGridProps) {
  const [chosen, setChosen] = useState<string[]>([]);
  const [finish, setFinish] = useState<string[]>([]);
  const [sort, setSort] = useState<Sort>('featured');
  const headingId = useId();

  const shown = useMemo(
    () =>
      [...products]
        .filter((product) => !chosen.length || chosen.includes(product.category))
        .filter((product) => !finish.length || product.finishes.some((id) => finish.includes(id)))
        .sort(order[sort]),
    [products, chosen, finish, sort],
  );

  const toggle = (list: string[], value: string, on: boolean) => (on ? [...list, value] : list.filter((entry) => entry !== value));

  return (
    <Section props={{ 'aria-labelledby': headingId }}>
      <Flex jc="space-between" ai="end" gap={4} flexWrap="wrap" mb={6}>
        <Box>
          <H2 id={headingId} fontSize={24} {...heading}>
            {title}
          </H2>
          <P mt={1} fontSize={14} color="stone-600" theme={{ dark: { color: 'stone-400' } }} props={{ 'aria-live': 'polite' }}>
            {shown.length} {shown.length === 1 ? 'object' : 'objects'}
          </P>
        </Box>
        <Dropdown<Sort>
          label="Sort by"
          value={sort}
          onValueChange={(value) => setSort((value as Sort) ?? 'featured')}
          labelProps={{ minWidth: 52 }}
        >
          {sorts.map((option) => (
            <Dropdown.Item key={option.value} value={option.value}>
              {option.label}
            </Dropdown.Item>
          ))}
        </Dropdown>
      </Flex>

      <Grid gridTemplateColumns={1} md={{ css: { gridTemplateColumns: '14rem minmax(0, 1fr)' } }} gap={8}>
        <Flex d="column" gap={6} fontSize={14}>
          <Filter legend="Category">
            {categories.map((category) => (
              <Checkbox
                key={category}
                label={category}
                checked={chosen.includes(category)}
                onChange={(event) => setChosen(toggle(chosen, category, event.target.checked))}
              />
            ))}
          </Filter>
          <Filter legend="Finish">
            {Object.values(finishes).map((entry) => (
              <Checkbox
                key={entry.id}
                label={entry.name}
                checked={finish.includes(entry.id)}
                onChange={(event) => setFinish(toggle(finish, entry.id, event.target.checked))}
              />
            ))}
          </Filter>
          {(chosen.length > 0 || finish.length > 0) && (
            <Button
              variant="secondary"
              alignSelf="start"
              py={1.5}
              px={4}
              fontSize={13}
              onClick={() => {
                setChosen([]);
                setFinish([]);
              }}
            >
              Clear filters
            </Button>
          )}
        </Flex>

        {shown.length ? (
          <Grid gridTemplateColumns={2} lg={{ gridTemplateColumns: 3 }} columnGap={5} rowGap={8}>
            {shown.map((product) => (
              <ProductCard key={product.slug} product={product} />
            ))}
          </Grid>
        ) : (
          <Flex
            d="column"
            ai="center"
            jc="center"
            gap={2}
            p={10}
            borderRadius={4}
            bgColor="stone-100"
            theme={{ dark: { bgColor: 'stone-900' } }}
          >
            <Span fontWeight={500}>Nothing matches those filters.</Span>
            <Span fontSize={14} color="stone-600" theme={{ dark: { color: 'stone-400' } }}>
              Try another finish, or clear them all.
            </Span>
          </Flex>
        )}
      </Grid>
    </Section>
  );
}

/** A group of checkboxes under one caption: a real `<fieldset>`, so the caption is read with each box. */
function Filter({ legend, children }: { legend: string; children: ReactNode }) {
  return (
    <Box tag="fieldset" b={0} p={0} m={0}>
      <Box
        tag="legend"
        mb={3}
        fontSize={12}
        fontWeight={500}
        letterSpacing={1}
        textTransform="uppercase"
        color="stone-600"
        theme={{ dark: { color: 'stone-400' } }}
      >
        {legend}
      </Box>
      <Flex d="column" gap={2.5}>
        {children}
      </Flex>
    </Box>
  );
}
```

**box-kite/store/product-card.tsx**

```jsx
'use client';
import Box from '@box-kite/react';
import Flex from '@box-kite/react/components/flex';
import { Article, H2, H3, Link, Span } from '@box-kite/react/components/semantics';
import VisuallyHidden from '@box-kite/react/components/visuallyHidden';
import ProductArt, { finishSwatch } from './product-art';
import { formatPrice, finishes, type Product } from './store-data';
import { eyebrow, useStoreLinks } from './store-design';

export interface ProductCardProps {
  product: Product;
  /** The heading level the card's name takes in the page around it. */
  level?: 'h2' | 'h3';
}

/**
 * One product in a grid. The whole card is the link, and its name is the link's text: the picture is
 * decoration beside it, and the finish swatches are spelled out for a screen reader in one sentence.
 */
export default function ProductCard({ product, level = 'h3' }: ProductCardProps) {
  const Heading = level === 'h2' ? H2 : H3;
  const links = useStoreLinks();
  const [first] = product.finishes;

  return (
    <Article position="relative" className="card">
      <Box position="relative" aspectRatio="square" transition="opacity" group={{ 'card/hover': { opacity: 0.8 } }}>
        <ProductArt shape={product.shape} finish={first} radius={4} />
        {product.badge && (
          <Span
            position="absolute"
            top={4}
            insetStart={4}
            px={2.5}
            py={1}
            borderRadius={999}
            {...eyebrow}
            fontSize={11}
            bgColor="white"
            color="stone-900"
          >
            {product.badge}
          </Span>
        )}
      </Box>
      <Flex jc="space-between" ai="baseline" gap={3} mt={4}>
        <Heading fontSize={15} fontWeight={500}>
          <Link
            props={{ href: links.product(product.slug) }}
            color="currentColor"
            textDecoration="none"
            // The link's box stretched over the card, so the whole card is one target and still one tab stop.
            after={{ position: 'absolute', inset: 0, borderRadius: 4 }}
            focusVisible={{ outline: 0, after: { outline: 2, outlineColor: 'stone-900', outlineOffset: 4 } }}
            theme={{ dark: { focusVisible: { after: { outlineColor: 'stone-100' } } } }}
          >
            {product.name}
          </Link>
        </Heading>
        <Span fontSize={15}>{formatPrice(product.price)}</Span>
      </Flex>
      <Flex ai="center" gap={1.5} mt={2.5}>
        {product.finishes.map((id) => (
          <Box
            key={id}
            width={2.5}
            height={2.5}
            borderRadius={999}
            ring={1}
            ringColor="stone-900/15"
            theme={{ dark: { ringColor: 'white/20' } }}
            {...finishSwatch(id)}
          />
        ))}
        <VisuallyHidden>Available in {product.finishes.map((id) => finishes[id].name).join(', ')}.</VisuallyHidden>
      </Flex>
    </Article>
  );
}
```

It stands on `store-design`, which the command installs with it. The design the store blocks and pages share: the components restyled with Box.createComponents(), the catalogue, the drawn product art, and the header and footer. [The design is on the template page.](https://www.box-kite.dev/templates/react-ecommerce-storefront.md#design)

## Used in

- [React ecommerce storefront template](https://www.box-kite.dev/templates/react-ecommerce-storefront.md)
- [React product page template](https://www.box-kite.dev/templates/react-product-page.md)

---

_Every page: https://www.box-kite.dev/llms.txt · every prop, measured: https://www.box-kite.dev/props.md_

