Product Filters
Faceted filters for a collection page, with chips, sort and load more. Handles zero results, slow counts, long value lists and price ranges in any currency.
Product Filters is the left side and the top of a collection page: a sidebar of facets, the applied filters as chips, a toolbar with the count, sort and grid size, and a Load more button with progress. On phones the same facets open in a bottom sheet. Facets are plain data, { id, label, type, values }, with list, swatch, range, boolean and rating types, so they map from Shopify, Algolia, Meilisearch or your own database.
On desktop every change applies at once and a polite live region says “12 results”. Each value shows its count, and values with no matches stay visible but disabled, so shoppers never pick their way into an empty grid. Long lists show six values and “Show 3 more”, and lists of 15 or more get a search field. Price is a two-thumb slider over a histogram plus two inputs with the currency symbol. The slider applies when you let go and the inputs on blur or Enter, so typing is never interrupted.
In the mobile sheet, changes are staged. The button reads “Show 24 results” and updates as you toggle, from onCount, which may be async. Stale answers are dropped, and the button turns into “No results” when nothing matches. Closing the sheet without applying throws the changes away.
Every applied value becomes a chip, such as Oat or $40–$120. Removing one moves focus to the next chip, and removing the last one moves it to a target you choose. On phones the chips scroll sideways, with a count and a faded edge. When nothing matches, FilterEmpty explains why and lists the chips to remove. The pure helpers, applyFilters, computeFacetCounts and the search param pair, work on the server too.
From Shopify’s productFilters
Map the Storefront API’s collection.products.filters to facets once. Each value keeps its input JSON, so toProductFilters turns the shopper’s selection straight back into the filters argument for the next query, price range and availability included.
From Algolia or Meilisearch
Build facets from facetDistribution and facetStats, and send the selection back as filter expressions. On mobile, onCount runs a limit: 0 search so the sheet’s button reads Show 128 results before anything is applied.
Filters in the URL with Next.js
filtersToSearchParams and filtersFromSearchParams read and write ?color=Oat&price=40-120&page=2 with router.replace, so links can be shared and Back works. Keeping the page in the URL lets the browser restore the scroll position after a product page, because pages 1 to 2 render again.
構造
外側から内側へ組み合わせるパーツ。
| パーツ | 説明 |
|---|---|
ProductFilters | The desktop sidebar: collapsible facet groups that apply live. |
FilterSheet | A bottom sheet with the same facets, staged changes and a Show results footer. |
ActiveFilters | Removable chips for every applied value, with Clear all. |
ListingToolbar | The result count, the sort select, grid size and a slot for the mobile Filters button. |
LoadMore | Showing 24 of 128, a progress bar and Load more, with loading and retry states. |
FilterEmpty | The no-results state, with the chips to remove and Clear all. |
applyFilters, computeFacetCounts | Filter items and recount every facet against the other facets. |
filtersToSearchParams, filtersFromSearchParams | Read and write the filters as URL search params, dropping unknown values. |
ProductFilters
Also takes div props.
| プロパティ | 型 | デフォルト |
|---|---|---|
facetsThe facets in display order, with current counts. | Facet[] | – |
valueThe applied filters, such as { color: ["Oat"], price: [40, 120], in_stock: true }. | FilterState | – |
defaultValueThe starting filters when uncontrolled. | FilterState | – |
onValueChangeCalled on every change, already normalized. | (state: FilterState) => void | – |
resultCountThe number of results. Each change is announced to screen readers. Pass undefined while loading. | number | – |
defaultOpenFacet ids that start expanded. Facets with an applied value always start expanded. All facets are expanded by default. | string[] | – |
visibleValuesValues shown before Show more in list facets. | number | 6 |
swatchVisibleValuesSwatches shown before Show more. | number | 12 |
searchThresholdList facets with this many values or more get a search field. | number | 15 |
titleThe heading. null hides the header and Clear all. | ReactNode | null | – |
localeLocale for prices and numbers. | Intl.LocalesArgument | "en-US" |
labelsWords for Show more, search, ratings, counts and announcements. | Partial<ProductFiltersLabels> | – |
| プロパティ | 型 | デフォルト |
|---|---|---|
facetsThe facets with counts for the applied filters. | Facet[] | – |
getFacetsRecount facets for the staged filters while the sheet is open. | (draft: FilterState) => Facet[] | – |
valueThe applied filters. The sheet starts from these each time it opens. | FilterState | – |
onValueChangeCalled once, when the shopper taps Show results. | (state: FilterState) => void | – |
resultCountThe count for the applied filters. | number | – |
onCountThe count for the staged filters. Throw to fall back to Show results. | (draft: FilterState) => number | Promise<number> | – |
openControlled open state, with onOpenChange and defaultOpen. | boolean | – |
triggerThe element that renders the Filters trigger. It shows the applied count. | ReactElement | <Button variant="outline" /> |
labelsWords for the trigger, title, Clear and the Show results button. | Partial<FilterSheetLabels> | – |
ActiveFilters
Also takes div props.
| プロパティ | 型 | デフォルト |
|---|---|---|
facetsThe facets, used to label each chip. | Facet[] | – |
valueThe applied filters. | FilterState | – |
onValueChangeCalled with the filters after a chip is removed or Clear all. | (state: FilterState) => void | – |
layoutA bar scrolls sideways on phones and wraps on desktop. wrap always wraps and centers. | "bar" | "wrap" | "bar" |
clearAllShow the Clear all button. | boolean | true |
fallbackFocusRefWhere focus goes after the last chip is removed. Defaults to the next focusable element. | RefObject<HTMLElement> | – |
labelsWords for chips, removal and announcements. | Partial<ActiveFiltersLabels> | – |
ListingToolbar
Also takes div props.
| プロパティ | 型 | デフォルト |
|---|---|---|
countThe total number of results. | number | – |
sortThe selected sort value, with onSortChange. | string | – |
sortOptionsDefaults to Featured, Best selling, Newest, both price orders and Top rated. | { value; label }[] | – |
densityThe grid size, with onDensityChange. | "comfortable" | "compact" | – |
densitiesThe grid size options. null hides the toggle. | DensityOption[] | null | – |
filtersShown below the md breakpoint, usually a FilterSheet. | ReactNode | – |
LoadMore
Also takes div props.
| プロパティ | 型 | デフォルト |
|---|---|---|
shownHow many products are on the page. | number | – |
totalHow many match. | number | – |
pageSizeShown in the button as Load 12 more. When everything fits on one page, LoadMore renders nothing. | number | – |
onLoadMoreLoad the next page. Throw to show the message and a Retry button. | () => void | Promise<unknown> | – |
loadingControl the loading state yourself. | boolean | – |
labelsWords for progress, loading, errors and the end of the list. | Partial<LoadMoreLabels> | – |
| プロパティ | 型 | デフォルト |
|---|---|---|
idThe key in FilterState and the URL. | string | – |
labelThe group heading, such as Color. | string | – |
typeHow the facet is shown and matched. | "list" | "swatch" | "range" | "boolean" | "rating" | – |
valuesFor list, swatch and rating facets. A rating value of "4" means 4 stars and up. | { value; label; count; swatch?: { color?; image? } }[] | – |
min, max, stepBounds and step for range facets. | number | – |
currencyCodeFormats a range as money, such as $40–$120. | string | – |
unitA unit for other ranges, such as cm. | string | – |
countMatches for a boolean facet. | number | – |
histogramBucket counts drawn above the price slider. computeFacetCounts fills it. | number[] | – |
| キー | アクション |
|---|---|
| Tab | Moves through facet headings, values, chips and the toolbar. |
| EnterSpace | Expands or collapses a facet group, or toggles a value. |
| ArrowLeftArrowRight | Moves a price thumb by one step, or moves between rating options. |
| Enter | Applies a typed minimum or maximum price. |
| Escape | Clears a facet search, or closes the filter sheet without applying it. |
| BackspaceDelete | Removes the focused chip. |
- Each facet heading is a button with its expanded state. Values are checkboxes, radios or a switch named by their label and count, such as “Oat, 12 products”. Disabled values stay visible.
- A polite live region announces the new result count after each change on desktop, and the sheet announces its count as you toggle. Load more announces how many products are shown, and its errors use role="alert".
- Removing a chip moves focus to the next chip. Removing the last one, or clearing the empty state, moves focus to the target you pass instead of dropping it on the page.
- Swatches show a ring and a check mark when selected, not just a color, and use system outlines in forced colors mode. With reduced motion, the expanding groups, histogram and image hover don’t animate.
使用技術
Product Filters を構成する無料のHextaUIコンポーネントです。それぞれ単独でインストールできます。
コード
11 個のファイルを components/blocks/product-filters に追加しました。