React product grid with filters

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 has the one-time setup.
Terminal
npx shadcn@latest add https://www.box-kite.dev/r/product-grid.json
Open in v0The item 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 to see it in yours.
light

Shop the collection

8 objects

Sort by
Category
Finish
Bestseller
Available in Clay, Chalk, Graphite.
New
Available in Sand, Clay, Sage.
Available in Graphite, Chalk, Sage.
Available in Sage, Chalk, Sand, Clay, Graphite.
Limited
Available in Graphite, Clay, Chalk.
dark

Shop the collection

8 objects

Sort by
Category
Finish
Bestseller
Available in Clay, Chalk, Graphite.
New
Available in Sand, Clay, Sage.
Available in Graphite, Chalk, Sage.
Available in Sage, Chalk, Sand, Clay, Graphite.
Limited
Available in Graphite, Clay, Chalk.

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.