# Product Listing

> Categories, filters, sorting, result counts, and pagination.

- Category: Commerce
- Kind: block
- Page: https://uaiblocks.dev/components/product-listing
- Registry item: https://uaiblocks.dev/r/product-listing.json
- Source: https://github.com/victor-teles/uai/blob/main/src/registry/uai/blocks/product-listing.tsx

## Install

```bash
bunx shadcn@latest add https://uaiblocks.dev/r/product-listing.json
```

## Usage

```tsx
"use client";

import { useId, useState } from "react";
import {
  ProductListing,
  ProductListingAside,
  ProductListingBody,
  ProductListingCategories,
  ProductListingCategory,
  ProductListingCount,
  ProductListingDescription,
  ProductListingFilters,
  ProductListingHeader,
  ProductListingMain,
  ProductListingPage,
  ProductListingPageNext,
  ProductListingPagePrevious,
  ProductListingPagination,
  ProductListingProduct,
  ProductListingProductBadge,
  ProductListingProductMedia,
  ProductListingProductMeta,
  ProductListingProductName,
  ProductListingProductPrice,
  ProductListingResults,
  ProductListingSort,
  ProductListingSortOption,
  ProductListingTitle,
  ProductListingToolbar,
  type ProductListingVariant,
} from "@/components/uai/product-listing";
import { Label } from "@/components/ui/label";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import {
  EmptyState,
  EmptyStateAction,
  EmptyStateActions,
  EmptyStateContent,
  EmptyStateDescription,
  EmptyStateHeader,
  EmptyStateTitle,
} from "@/components/ui/uai/empty-state";
import {
  FilterBarChip,
  FilterBarChips,
  FilterBarControls,
  FilterBarReset,
} from "@/components/ui/uai/filter-bar";

const products = [
  { name: "Stoneware pour-over set", glaze: "Ash", price: 68, inStock: true, tone: "#8f8c86" },
  { name: "Low tumbler, set of 2", glaze: "Moss", price: 34, inStock: true, tone: "#7d8a6a" },
  { name: "Serving bowl, 24 cm", glaze: "Clay", price: 52, inStock: false, tone: "#b07a5c" },
  { name: "Espresso cup and saucer", glaze: "Ash", price: 26, inStock: true, tone: "#a19e97" },
  { name: "Bud vase, tall", glaze: "Moss", price: 22, inStock: true, tone: "#6f7a5e" },
  { name: "Dinner plate, 27 cm", glaze: "Clay", price: 30, inStock: true, tone: "#9c6b52" },
];

export function ProductListingPreview({ variant = "grid" }: { variant?: ProductListingVariant }) {
  const glazeId = useId();
  const [glaze, setGlaze] = useState("");
  const [inStock, setInStock] = useState(false);
  const [sort, setSort] = useState("featured");
  const results = products
    .filter((item) => (!glaze || item.glaze === glaze) && (!inStock || item.inStock))
    .sort((a, b) =>
      sort === "price-asc" ? a.price - b.price : sort === "price-desc" ? b.price - a.price : 0,
    );
  const filters = (
    <ProductListingFilters
      activeCount={Number(Boolean(glaze)) + Number(inStock)}
      onReset={() => {
        setGlaze("");
        setInStock(false);
      }}
    >
      <FilterBarControls>
        <span style={{ display: "flex", gap: 8, alignItems: "center" }}>
          <Label htmlFor={glazeId}>Glaze</Label>
          <Select
            value={glaze || "all"}
            onValueChange={(next) => setGlaze(next === "all" ? "" : next)}
          >
            <SelectTrigger
              id={glazeId}
              size="sm"
              className="h-7 gap-1.5 rounded-full border-0 bg-secondary px-2.5 text-[12.5px] font-medium shadow-none dark:bg-secondary"
            >
              <SelectValue />
            </SelectTrigger>
            <SelectContent>
              <SelectItem value="all">All glazes</SelectItem>
              <SelectItem value="Ash">Ash</SelectItem>
              <SelectItem value="Moss">Moss</SelectItem>
              <SelectItem value="Clay">Clay</SelectItem>
            </SelectContent>
          </Select>
        </span>
        <label style={{ display: "flex", gap: 6, alignItems: "center" }}>
          <input
            type="checkbox"
            checked={inStock}
            onChange={(event) => setInStock(event.target.checked)}
          />
          In stock only
        </label>
      </FilterBarControls>
      <FilterBarChips>
        {glaze && <FilterBarChip onRemove={() => setGlaze("")}>Glaze: {glaze}</FilterBarChip>}
        {inStock && <FilterBarChip onRemove={() => setInStock(false)}>In stock</FilterBarChip>}
      </FilterBarChips>
      <FilterBarReset />
    </ProductListingFilters>
  );
  return (
    <ProductListing variant={variant}>
      <ProductListingHeader>
        <ProductListingTitle>Tableware</ProductListingTitle>
        <ProductListingDescription>
          Small-batch stoneware, glazed and fired in our studio.
        </ProductListingDescription>
      </ProductListingHeader>
      <ProductListingCategories>
        <ProductListingCategory href="#all">All</ProductListingCategory>
        <ProductListingCategory href="#tableware" current>
          Tableware
        </ProductListingCategory>
        <ProductListingCategory href="#brewing">Brewing</ProductListingCategory>
        <ProductListingCategory href="#vases">Vases</ProductListingCategory>
      </ProductListingCategories>
      <ProductListingBody>
        {variant === "sidebar" ? <ProductListingAside>{filters}</ProductListingAside> : null}
        <ProductListingMain>
          {variant === "sidebar" ? null : filters}
          <ProductListingToolbar>
            <ProductListingCount>
              {results.length} of {products.length} products
            </ProductListingCount>
            <ProductListingSort value={sort} onValueChange={setSort}>
              <ProductListingSortOption value="featured">Featured</ProductListingSortOption>
              <ProductListingSortOption value="price-asc">
                Price: low to high
              </ProductListingSortOption>
              <ProductListingSortOption value="price-desc">
                Price: high to low
              </ProductListingSortOption>
            </ProductListingSort>
          </ProductListingToolbar>
          {results.length === 0 ? (
            <EmptyState variant="plain">
              <EmptyStateContent>
                <EmptyStateHeader>
                  <EmptyStateTitle>No products match these filters</EmptyStateTitle>
                  <EmptyStateDescription>
                    Try another glaze or include items that are back-ordered.
                  </EmptyStateDescription>
                </EmptyStateHeader>
                <EmptyStateActions>
                  <EmptyStateAction
                    onClick={() => {
                      setGlaze("");
                      setInStock(false);
                    }}
                  >
                    Clear filters
                  </EmptyStateAction>
                </EmptyStateActions>
              </EmptyStateContent>
            </EmptyState>
          ) : (
            <ProductListingResults>
              {results.map((item) => (
                <ProductListingProduct key={item.name}>
                  <ProductListingProductMedia>
                    <svg viewBox="0 0 100 100" width="100%" height="100%" aria-hidden="true">
                      <ellipse cx="50" cy="80" rx="26" ry="3" fill="oklch(0 0 0 / 0.12)" />
                      <rect x="28" y="40" width="44" height="40" rx="12" fill={item.tone} />
                    </svg>
                  </ProductListingProductMedia>
                  <ProductListingProductName href={`#${item.name}`}>
                    {item.name}
                  </ProductListingProductName>
                  <ProductListingProductMeta>
                    {item.glaze} glaze{item.inStock ? "" : " · Back-ordered"}
                  </ProductListingProductMeta>
                  <ProductListingProductPrice>${item.price}</ProductListingProductPrice>
                  {item.price < 25 ? (
                    <ProductListingProductBadge>Under $25</ProductListingProductBadge>
                  ) : null}
                </ProductListingProduct>
              ))}
            </ProductListingResults>
          )}
          <ProductListingPagination>
            <ProductListingPagePrevious disabled href="#page-0" />
            <ProductListingPage href="#page-1" current>
              1
            </ProductListingPage>
            <ProductListingPage href="#page-2">2</ProductListingPage>
            <ProductListingPage href="#page-3">3</ProductListingPage>
            <ProductListingPageNext href="#page-2" />
          </ProductListingPagination>
        </ProductListingMain>
      </ProductListingBody>
    </ProductListing>
  );
}
```

## Anatomy

- `ProductListingFilters`
  - `FilterBarControls`
  - `FilterBarChips`
  - `FilterBarReset`
- `ProductListing`
  - `ProductListingHeader`
    - `ProductListingTitle`
    - `ProductListingDescription`
  - `ProductListingCategories`
    - `ProductListingCategory`
  - `ProductListingBody`
    - `ProductListingMain`
      - `ProductListingToolbar`
        - `ProductListingCount`
        - `ProductListingSort`
          - `ProductListingSortOption`
        - `EmptyState`
          - `EmptyStateContent`
            - `EmptyStateHeader`
              - `EmptyStateTitle`
              - `EmptyStateDescription`
            - `EmptyStateActions`
              - `EmptyStateAction`
        - `ProductListingResults`
          - `ProductListingProduct`
            - `ProductListingProductMedia`
            - `ProductListingProductName`
            - `ProductListingProductMeta`
            - `ProductListingProductPrice`
              - `ProductListingProductBadge`
      - `ProductListingPagination`
        - `ProductListingPagePrevious`
        - `ProductListingPage`
        - `ProductListingPageNext`

## Accessibility

- Categories and pagination are labelled nav landmarks of real links; the current entry carries aria-current="page".
- The result count is a polite status region, so filter and sort changes are announced without moving focus.
- Sort is a labelled select that opens a listbox; filters are Filter Bar controls with named remove buttons on each chip.
- Each product is a list item labelled by its h3 name; the name link stretches across the card and shows one visible focus ring on the card.
- Disabled previous and next links drop their href and set aria-disabled.
