Show a facet as swatches
A product listing shows every options facet as a list of checkboxes. For color, shoppers expect swatches. This guide adds a swatch component and sends the color facet to it, in the reference storefronts’ listing filters.
The filters are your site’s own code, not a feature package. They live in
frontend/site/src/app/[locale]/(main)/(product-listing)/_components/filters/:
| File | What it does |
|---|---|
facet-input.tsx |
Picks the component for each facet, and turns a change into a new URL |
options-facet-desktop.tsx |
An OptionsFacet as checkboxes, and the OptionsFacetFragment it reads |
options-facet-with-search.tsx |
The same with a search box, used for brand |
filters-desktop.tsx, filters-mobile.tsx |
The two layouts. Both render each facet through FacetInput |
Because both layouts go through FacetInput, one change there covers desktop
and mobile.
Find the facet’s key
Section titled “Find the facet’s key”FacetInput can only tell facets apart by their key. Which key your color
facet has depends on the search behind your listing:
| Search | Key of a color attribute |
|---|---|
| commercetools product search | color |
| Meilisearch | attr_color |
| Algolia | The attribute’s name in attributesForFaceting |
Check what your listing returns before you rely on it. Which facets exist at all is a backend setting; see Facets and sorting.
Each option’s key is the attribute value, such as black. It is also what
the filter puts in the URL.
Add the swatch component
Section titled “Add the swatch component”The component takes the same props as OptionsFacetDesktop, so FacetInput
can call it the same way. Each swatch is a real checkbox, hidden but focusable,
with a round label that shows the color:
import { cn } from "@evolve-storefront/ui/helpers/styles";import type { ResultOf } from "@graphql-typed-document-node/core";import type { ReactNode } from "react";import type { OptionsFacetFragment } from "./options-facet-desktop.tsx";
// Option keys are the attribute values in your catalog, lowercased here.const SWATCH_COLORS: Record<string, string> = { black: "#000000", white: "#ffffff", red: "#d62828", blue: "#1d4ed8", green: "#15803d",};
const FALLBACK_COLOR = "#d4d4d4";
type Props = { data: ResultOf<typeof OptionsFacetFragment>; className?: string; onChange: (value: string, selected: boolean) => void; isPending: boolean;};
export const OptionsFacetSwatches = ({ data, className, onChange, isPending,}: Props): ReactNode => ( <ul className={cn("mt-4 flex flex-wrap gap-3", className)} data-pending={isPending ? "true" : undefined} > {data.options.map((option) => ( <li key={option.key}> <label title={`${option.label} (${option.count})`}> <input type="checkbox" className="peer sr-only" name={data.key} value={option.key} checked={option.selected} disabled={option.count === 0 || isPending} onChange={(event) => onChange(option.key, event.target.checked)} /> <span aria-hidden className={cn( "block size-8 rounded-full border border-gray-200", "peer-checked:ring-2 peer-checked:ring-black peer-checked:ring-offset-2", "peer-focus-visible:outline-2 peer-focus-visible:outline-offset-4", "peer-disabled:opacity-40", )} style={{ backgroundColor: SWATCH_COLORS[option.key.toLowerCase()] ?? FALLBACK_COLOR, }} /> <span className="sr-only"> {option.label} ({option.count}) </span> </label> </li> ))} </ul>);SWATCH_COLORSmaps option keys to colors. A value it does not know gets the gray fallback, and its label still reaches screen readers and the tooltip. Keep the map next to the component, or move it to a shared file if the product page needs the same colors.- The checkbox comes first in each label, so Tailwind’s
peer-*variants can style the swatch from its state: a ring when checked, an outline on keyboard focus, faded when no product has that color. checkedfollowsoption.selected. The selection lives in the URL, and the listing renders again with the new facets after each change.
If your attribute is localized text, its values, and so the option keys, differ per language. Map every language’s values, or make the attribute an enum so its keys stay the same.
Route the color facet to it
Section titled “Route the color facet to it”In facet-input.tsx, import the component and send the color facet to it,
before the brand check:
import { OptionsFacetSwatches } from "./options-facet-swatches.tsx";
// ...
export function FacetInput({ /* ... */ }: Props): ReactNode { // ... switch (facetResult.__typename) { case "OptionsFacet": if (facetResult.key === "color") { return ( <OptionsFacetSwatches isPending={isPending} data={facetResult} className={className} onChange={(value, selected) => { const url = new URL(window.location.href); if (selected) { url.searchParams.append(facetResult.key, value); } else { url.searchParams.delete(facetResult.key, value); } startTransition(() => { onChange(url); router.push(url.toString()); }); }} /> ); } if (facetResult.key === "brand") { // ...the existing branchesUse the key you found in Find the facet’s key. The
onChange is the same as the other options facets’: it adds or removes the
value in the URL, and navigates in a transition so the listing shows that it
is loading.
The OptionsFacet type is shared by all options facets, so the swatch
component needs no change to the GraphQL query. The active filters above the
listing show the option’s label, as before.
Check it
Section titled “Check it”- Open a category with products in more than one color. The color facet shows swatches, on desktop and in the mobile filters.
- Select a swatch. The URL gets
?color=<key>, the listing narrows, and the swatch shows its ring. - Tab through the facet. Each swatch gets a focus outline, and Space toggles it.
- Look for gray swatches. Each is a value missing from
SWATCH_COLORS.

