> ## Documentation Index
> Fetch the complete documentation index at: https://docs.depict.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Custom search UI: filters and suggestions

> Patterns for building filter controls and query-suggestion rendering on the Depict storefront search API, and the client-side traps that make them misbehave.

<Warning>
  **This API will be removed at the end of this year.** It is the
  current-generation storefront search API, and it is the supported and
  recommended way to build a custom search UI today — the Depict search modal
  runs on exactly these endpoints. A successor API is coming, and every team
  building on this one should plan for a migration before year end. Migration
  documentation will follow; ask your Depict contact to be notified when it is
  published.
</Warning>

The API gives you filter field names and a product array; the filter controls
themselves are yours to build. So is the markup for query suggestions. Both are
straightforward to get *working* and easy to get subtly wrong — the failures on
this page all pass a quick manual test and only show up once a shopper combines
two things.

Everything here is client-side behaviour. See the
[API reference](/shopify-search/custom-search-ui/api-reference) for the
wire shapes.

## Filter UIs

### Where option values come from

`filter_facets` lists the **field names** enabled for your account. It carries
no values and no counts:

```
filter_facets:["colors","material","gender","product_type"]
```

Values come from one of two places:

* **`size` is server-counted.** Sizes live on variants and results are grouped
  by product, so they cannot be read off the product cards. When the facet is
  enabled the backend sends them on their own stream key, pre-sorted, and
  scoped so that selecting one size does not remove the others:

  ```
  facet_counts:{"size":[{"value":"M","count":7},{"value":"L","count":3}]}
  ```

* **Every other field is derived by you**, from the products in the response.

That second case is where the trap lives, because **the products in the
response are already scoped by the filters you sent**. Derive naively and each
facet narrows itself.

### A facet must never narrow its own option list

Say `gender` offers `female` and `male`. The shopper picks `male`, you send the
filter, and the response now contains only men's products — so the only `gender`
value you can derive is `male`. Rebuild the control from that and `female`
disappears. The multi-select has become a one-way door: the shopper cannot get
back, cannot select both, and the group looks broken.

Cross-facet narrowing is the opposite case and it is **correct**. If the shopper
filters by `gender`, the `material` options *should* shrink to the materials
men's products actually come in. Only a facet's own filter must be excluded from
its own options.

The fix is to remember what each field offered before it was filtered, and
re-offer those values at count 0 while that field has a selection:

```javascript theme={null}
// Every value each field has offered during the CURRENT query. Cleared when the
// query changes; carried across filter changes, which is the case it exists for.
const knownValues = new Map(); // field -> Set<string>
let knownValuesKey = null;

const ARRAY_FIELDS = new Set(["colors", "occasions", "tags", "style_attributes"]);
const valuesOf = (field, product) =>
  ARRAY_FIELDS.has(field)
    ? product[field] ?? []
    : product[field]
      ? [product[field]]
      : [];

function rememberValues(key, fields, products) {
  if (key !== knownValuesKey) {
    knownValues.clear();
    knownValuesKey = key;
  }
  for (const field of fields) {
    const seen = knownValues.get(field) ?? new Set();
    for (const product of products) {
      for (const value of valuesOf(field, product)) if (value) seen.add(value);
    }
    knownValues.set(field, seen);
  }
}
```

Call this with every `keyword` payload you receive, including later pages — more
products means more values observed, and the set only grows within one query.

Key the memory on everything that changes the vocabulary — the query, and also
`locale` and `country`. Attribute values are localised, so the same field
returns different words per market; a market switch mid-session must start the
memory over rather than merge two languages into one control.

The memory only ever re-offers values your catalogue actually returned in this
locale, which is why it is a memory and not a hardcoded list.

Then build the options for one field:

```javascript theme={null}
function optionsFor(field, products, selected, facetCounts) {
  // Server-counted fields arrive complete and pre-sorted. Render them verbatim
  // and do not re-sort: the order is meaningful (size ladders, for instance).
  if (facetCounts[field]) return facetCounts[field];

  const counts = new Map();
  for (const product of products) {
    for (const value of valuesOf(field, product)) {
      if (value) counts.set(value, (counts.get(value) ?? 0) + 1);
    }
  }

  if (selected.length > 0) {
    // Only while THIS field is filtered. With nothing selected, the narrowing
    // comes from other fields and must show through.
    for (const value of knownValues.get(field) ?? []) {
      if (!counts.has(value)) counts.set(value, 0);
    }
    // And never lose the selection itself — see below.
    for (const value of selected) {
      if (!counts.has(value)) counts.set(value, 0);
    }
  }

  return [...counts.entries()]
    .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
    .map(([value, count]) => ({ value, count }));
}
```

<Note>
  This approximates a disjunctive facet rather than implementing one. A true
  disjunctive count for `gender` would be computed over "every filter except
  `gender`", which the client cannot see — it only ever receives products
  matching *all* filters. So a re-offered value can occasionally be an option
  that yields nothing once a second field is also filtered. That is the right
  trade: an option that returns no results is recoverable, an option that has
  vanished is not.
</Note>

### Keep a selected value visible, even at count 0

A selected value must stay in the list whatever the counts say. It is not just
an option — it is the control that *clears* the filter. Drop it and the shopper
is holding an active filter with nothing on screen to switch it off, and the
only way out is a global "clear all".

This is why the snippet above seeds `selected` separately from `knownValues`:
the memory is only consulted while the field has a selection, but the selection
is always re-added.

<Warning>
  The dead end is easy to miss in testing because it needs two filters to
  reproduce: a combination that returns no products at all will empty *every*
  derived option list at once. Test a deliberately empty result set and confirm
  each active filter is still visible and still clearable.
</Warning>

### Zero-count options must stay clickable

Greying out or disabling a zero-count option looks tidy and breaks the control.

Consider a single-select-style `gender` facet with `male` selected. In the
men-filtered results `female` has count 0. Disable it, and the shopper can never
switch to `female` — the only enabled option is the one already selected. The
facet is stuck on its first choice for the rest of the session.

Render zero-count options as normal, clickable options. Clicking one should do
what clicking any option does: add it to the selection, or swap the selection if
your control is single-select. If you want to signal that an option is thinly
populated, do it with styling that does not remove the click target.

### Optional refinements

* **Hide an empty group.** A field whose derived options are empty *and* which
  has no selection can be hidden — there is nothing to choose. Do not hide a
  group that has a selection, for the reason above.
* **Seed closed vocabularies.** Some fields have a small, fixed set of values —
  `gender` is usually `female` / `male`, sometimes with `unisex`. If you know
  the full set for your catalogue, you can seed the control statically and give
  each value a display label ("Women", "Men") instead of showing the raw
  indexed value. Two cautions: the values are localised, so a static list is
  only safe for the locales you have actually checked, and the value you send
  in a filter must still be the exact indexed value, not the label.
* **Do not re-sort server-counted options.** They arrive in display order.

### Sending the filter

Each entry is `{ field, operator, value }`, and the values are echoed back
verbatim from what you derived:

```json theme={null}
{
  "filters": [
    { "field": "gender", "operator": "in", "value": ["male"] },
    { "field": "colors", "operator": "in", "value": ["black", "brown"] }
  ]
}
```

Multi-select facets use `in` with an array. Full operator and field list, and
the rules about which fields are safe to filter on, are in the
[API reference](/shopify-search/custom-search-ui/api-reference#filters).

<Note>
  Changing a filter is a new search: re-run `/search_v2` with a fresh
  `search_id`, debounce rapid toggles, and cancel the in-flight request when a
  new one starts.
</Note>

## Rendering query suggestions

### The format

`/autocomplete_v2` returns suggestions in a markdown-lite form where `**` marks
the **completion** — the part the shopper has not typed yet:

```json theme={null}
{ "suggestions": ["loaf**ers**", "loaf**ers in dark brown suede**"] }
```

The typed prefix stays plain; the span inside `**` is what you bold. Split on
`**`, escape every part, and wrap the odd-indexed parts:

```javascript theme={null}
const escapeHtml = (value) =>
  String(value).replace(
    /[&<>"']/g,
    (c) =>
      ({
        "&": "&amp;",
        "<": "&lt;",
        ">": "&gt;",
        '"': "&quot;",
        "'": "&#39;",
      })[c]
  );

// "loaf**ers**" -> loaf<strong>ers</strong>
const suggestionHtml = (raw) =>
  raw
    .split("**")
    .map((part, i) =>
      i % 2 === 1 ? `<strong>${escapeHtml(part)}</strong>` : escapeHtml(part)
    )
    .join("");

// What you send to /search_v2, and what you compare against.
const suggestionText = (raw) => raw.replace(/\*\*/g, "");
```

<Warning>
  Search and match against the **plain text**, never the raw string. The markers
  are presentation only — passing `loaf**ers**` to `/search_v2` as the query
  will not match what the suggestion was validated against.
</Warning>

### Whitespace: the disappearing space

When the completion starts a new word, its leading space is **inside** the `**`
span. After parsing you have two sibling nodes, and the space is the first
character of the second one:

```html theme={null}
loafers<strong> in dark brown suede</strong>
```

If the row containing them is a flex container, that leading space is dropped
and the suggestion renders as `loafersin dark brown suede`. The parsing is
correct; CSS ate the space.

Either set `white-space: pre-wrap` on the element that holds the suggestion
text, or do not make that element a flex container — put the flex layout on a
wrapper (icon, text, chevron) and let the text itself be a plain inline
container.

```css theme={null}
.suggestion-text {
  white-space: pre-wrap;
}
```

<Note>
  The same applies to any layout that reflows text nodes independently, not just
  flex. If the space is present in your parsed output but missing on screen, it
  is a CSS problem, not a parsing one.
</Note>

### Ordering

Suggestion responses are CDN-cached, so a response for an earlier keystroke can
arrive after a newer one. Record which query each response belongs to and
discard stale ones, or the list will flicker back to older completions as the
shopper types.

## Next

<CardGroup cols={2}>
  <Card title="API reference" icon="code" href="/shopify-search/custom-search-ui/api-reference">
    Every endpoint, field, stream key and filter.
  </Card>

  <Card title="Tracking events" icon="chart-line" href="/shopify-search/custom-search-ui/tracking">
    The events to send, and how attribution works.
  </Card>
</CardGroup>
