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.
1. 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 {
__typename
key # e.g. "color"
label # e.g. "Color"
options {
key # e.g. "red", "blue"
label # e.g. "Red", "Blue"
count
selected
}
}
The option key values (e.g. "red", "navy-blue") are what you'll map to
hex codes.
2. 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.tsx
The 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 (
<div
className={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 (
<button
key={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.
3. Route the facet in facet-input.tsx
Open facet-input.tsx and 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 (
<ColorSwatchFacet
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());
});
}}
/>
);
} 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.
4. Handle the color-to-hex mapping
The COLOR_MAP approach 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.
5. Mobile variant
The mobile filter sheet uses the same FacetInput component as desktop, so
adding the "color" case in facet-input.tsx automatically 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 ColorSwatchFacet and adjust sizing or wrapping accordingly.
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