Skip to content

New: AI agent integration via Model Context Protocol (MCP).Learn more

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.