Skip to content

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.

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.

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_COLORS maps 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.
  • checked follows option.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.

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 branches

Use 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.

  1. Open a category with products in more than one color. The color facet shows swatches, on desktop and in the mobile filters.
  2. Select a swatch. The URL gets ?color=<key>, the listing narrows, and the swatch shows its ring.
  3. Tab through the facet. Each swatch gets a focus outline, and Space toggles it.
  4. Look for gray swatches. Each is a value missing from SWATCH_COLORS.