Skip to main content

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 OptionsFacetFragment from options-facet-desktop.tsx, keeping GraphQL data requirements unchanged.
  • COLOR_MAP lookup: 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 the OptionsFacet options.
  • 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