Adding a custom facet component
The product listing page renders facets generically: checkboxes for options,
sliders for ranges. Sometimes you need a richer UI for a specific facet, such
as color swatches instead of text labels. This guide walks through adding a
color-swatch facet for an OptionsFacet with key "color".
The key insight: you don’t need a new GraphQL facet type. The backend already
exposes options facets generically. You customise the rendering of an
existing OptionsFacet based on its key.
-
Identify the facet key
Check your facet configuration to confirm the key used for colors. The facet data coming from the backend looks like this:
fragment OptionsFacetFragment on OptionsFacet {__typenamekey # e.g. "color"label # e.g. "Color"options {key # e.g. "red", "blue"label # e.g. "Red", "Blue"countselected}}The option
keyvalues (e.g."red","navy-blue") are what you’ll map to hex codes. -
Create the color swatch component
Create a new file following the pattern of the existing
OptionsFacetDesktop:frontend/site/src/app/[locale]/(main)/(product-listing)/_components/filters/color-swatch-facet.tsxThe component reuses the same
OptionsFacetFragment, so no new fragment is needed:import { cn } from "@evolve-storefront/ui/helpers/styles.ts";import type { ResultOf } from "@graphql-typed-document-node/core";import { type ReactNode, useEffect, useState } from "react";import type { OptionsFacetFragment } from "./options-facet-desktop.tsx";const COLOR_MAP: Record<string, string> = {red: "#DC2626",blue: "#2563EB",green: "#16A34A",black: "#171717",white: "#FAFAFA","navy-blue": "#1E3A5F",// Add more mappings as needed};type Props = {data: ResultOf<typeof OptionsFacetFragment>;className?: string;onChange: (value: string, selected: boolean) => void;isPending: boolean;};export const ColorSwatchFacet = ({data,className,onChange,isPending,}: Props): ReactNode => {const [clickedKey, setClickedKey] = useState<string | null>(null);useEffect(() => {if (!isPending) setClickedKey(null);}, [isPending]);return (<divclassName={cn("mt-4 flex flex-wrap gap-3", className)}data-pending={isPending ? "true" : undefined}>{data.options.map((item) => {const hex = COLOR_MAP[item.key] ?? "#CBD5E1";return (<buttonkey={item.key}type="button"title={`${item.label} (${item.count})`}disabled={item.count === 0 || isPending}className={cn("size-8 rounded-full border-2 transition-transform",item.selected? "border-black scale-110": "border-gray-300 hover:scale-105",(item.count === 0 || isPending) && "opacity-40 cursor-not-allowed",)}style={{ backgroundColor: hex }}onClick={() => {setClickedKey(item.key);onChange(item.key, !item.selected);}}/>);})}</div>);};Key decisions:
- Same fragment, different UI: the component uses
OptionsFacetFragmentfromoptions-facet-desktop.tsx, keeping GraphQL data requirements unchanged. COLOR_MAPlookup: maps option keys to hex values. For a more dynamic approach, you could store hex codes as attribute metadata in commercetools and expose them through theOptionsFacetoptions.- Selected state: a thicker border and slight scale increase indicate the active swatch, matching the checkbox behavior of the default component.
- Same fragment, different UI: the component uses
-
Route the facet in
facet-input.tsxOpen
facet-input.tsxand add a case for the"color"key, following the same pattern as the existing"brand"routing:import { ColorSwatchFacet } from "./color-swatch-facet.tsx";// Inside the OptionsFacet case:case "OptionsFacet":if (facetResult.key === "brand") {return (<OptionsFacetWithSearch /* ... */ />);} else if (facetResult.key === "color") {return (<ColorSwatchFacetisPending={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());});}}/>);} else {return (<OptionsFacetDesktop /* ... */ />);}The URL mutation logic is identical to the other options facets: append or delete the search parameter based on the selection state.
-
Handle the color-to-hex mapping
The
COLOR_MAPapproach works for a small, known set of colors. For a more scalable solution, consider:- Attribute metadata: store hex codes as product attribute values in commercetools and expose them in the facet response. This avoids hardcoding and works for any color catalog.
- CSS custom properties: if your design system already defines color tokens, map facet keys to token names instead of raw hex values.
-
Mobile variant
The mobile filter sheet uses the same
FacetInputcomponent as desktop, so adding the"color"case infacet-input.tsxautomatically applies to both. No separate mobile component or routing is needed.If the swatch layout doesn’t work well on smaller screens, you can check the viewport inside
ColorSwatchFacetand adjust sizing or wrapping accordingly.
Further reading
Section titled “Further reading”- Frontend development for component patterns and data fetching
- Architecture overview for how facets flow from the search service through the gateway to the frontend

