HextaUI

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.

Open in new tab

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.

  1. Procurez-vous HextaUI Pro

    Un seul paiement débloque tous les blocs, y compris les prochains. Voir les tarifs.

  2. Ajouter le registre Pro à components.json

    components.json
    {
      "registries": {
        "@hextaui-pro": {
          "url": "https://hextaui.com/r/pro/{name}.json",
          "headers": {
            "Authorization": "Bearer ${HEXTAUI_PRO_TOKEN}"
          }
        }
      }
    }
  3. Ajouter votre token

    Créez un token sur votre page de compte et placez-le dans .env.local sous le nom HEXTAUI_PRO_TOKEN.

  4. Ajouter le block

    pnpm dlx shadcn@latest add @hextaui-pro/product-filters

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.

"use client"

import type { Facet, FilterState } from "@/components/blocks/product-filters/facets"
import { ProductFilters } from "@/components/blocks/product-filters/product-filters"

type StorefrontFilterValue = {
  id: string
  label: string
  count: number
  input: string
  swatch?: { color?: string | null; image?: { previewImage?: { url: string } | null } | null } | null
}

type StorefrontFilter = {
  id: string
  label: string
  type: "LIST" | "PRICE_RANGE" | "BOOLEAN"
  presentation?: "TEXT" | "SWATCH" | "IMAGE" | null
  values: StorefrontFilterValue[]
}

type ProductFilterInput = Record<string, unknown>

export function toFacets(filters: StorefrontFilter[], currencyCode: string): Facet[] {
  return filters.map((filter): Facet => {
    if (filter.type === "PRICE_RANGE") {
      const range = JSON.parse(filter.values[0]?.input ?? "{}") as {
        price?: { min?: number; max?: number }
      }
      return {
        id: filter.id,
        label: filter.label,
        type: "range",
        min: Math.floor(range.price?.min ?? 0),
        max: Math.ceil(range.price?.max ?? 0),
        step: 5,
        currencyCode,
      }
    }
    if (filter.type === "BOOLEAN") {
      const yes = filter.values.find((value) => value.input.includes("true"))
      return { id: filter.id, label: filter.label, type: "boolean", count: yes?.count }
    }
    return {
      id: filter.id,
      label: filter.label,
      type: filter.presentation === "SWATCH" ? "swatch" : "list",
      values: filter.values.map((value) => ({
        value: value.input,
        label: value.label,
        count: value.count,
        swatch: value.swatch
          ? {
              color: value.swatch.color ?? undefined,
              image: value.swatch.image?.previewImage?.url,
            }
          : undefined,
      })),
    }
  })
}

export function toProductFilters(
  filters: StorefrontFilter[],
  state: FilterState
): ProductFilterInput[] {
  return filters.flatMap((filter): ProductFilterInput[] => {
    const value = state[filter.id]
    if (value === undefined) return []
    if (filter.type === "PRICE_RANGE" && Array.isArray(value)) {
      return [{ price: { min: Number(value[0]), max: Number(value[1]) } }]
    }
    if (filter.type === "BOOLEAN" && value === true) {
      const yes = filter.values.find((item) => item.input.includes("true"))
      return yes ? [JSON.parse(yes.input) as ProductFilterInput] : []
    }
    return Array.isArray(value)
      ? value.map((input) => JSON.parse(String(input)) as ProductFilterInput)
      : []
  })
}

export function CollectionFilters({
  filters,
  value,
  total,
  onFilter,
}: {
  filters: StorefrontFilter[]
  value: FilterState
  total: number
  onFilter: (input: ProductFilterInput[], state: FilterState) => void
}) {
  return (
    <ProductFilters
      facets={toFacets(filters, "USD")}
      value={value}
      resultCount={total}
      onValueChange={(next) => onFilter(toProductFilters(filters, next), next)}
    />
  )
}

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.

"use client"

import type { Facet, FilterState } from "@/components/blocks/product-filters/facets"
import { FilterSheet } from "@/components/blocks/product-filters/filter-sheet"
import { ProductFilters } from "@/components/blocks/product-filters/product-filters"

type FacetDistribution = Record<string, Record<string, number>>

type SearchResponse = {
  estimatedTotalHits: number
  facetDistribution: FacetDistribution
  facetStats?: Record<string, { min: number; max: number }>
}

type SearchClient = {
  search: (query: string, options: { filter: string[]; facets: string[]; limit: number }) => Promise<SearchResponse>
}

const facetIds = ["category", "color", "price", "in_stock"]

const quote = (value: string) => `"${value.replace(/"/g, '\\"')}"`

export function toFilterExpressions(state: FilterState) {
  return Object.entries(state).map(([id, value]) => {
    if (value === true) return `${id} = true`
    if (Array.isArray(value) && typeof value[0] === "number") {
      return `${id} ${value[0]} TO ${value[1]}`
    }
    return (value as string[]).map((item) => `${id} = ${quote(item)}`).join(" OR ")
  })
}

export function toFacets(response: SearchResponse): Facet[] {
  const list = (id: string, label: string, type: "list" | "swatch"): Facet => ({
    id,
    label,
    type,
    values: Object.entries(response.facetDistribution[id] ?? {}).map(
      ([value, count]) => ({ value, label: value, count })
    ),
  })
  const price = response.facetStats?.price
  return [
    { id: "in_stock", label: "In stock only", type: "boolean", count: response.facetDistribution.in_stock?.true ?? 0 },
    list("category", "Category", "list"),
    { id: "price", label: "Price", type: "range", min: Math.floor(price?.min ?? 0), max: Math.ceil(price?.max ?? 0), step: 5, currencyCode: "USD" },
    list("color", "Color", "swatch"),
  ]
}

export function SearchFilters({
  client,
  response,
  value,
  onValueChange,
}: {
  client: SearchClient
  response: SearchResponse
  value: FilterState
  onValueChange: (state: FilterState) => void
}) {
  const facets = toFacets(response)
  const count = async (draft: FilterState) => {
    const result = await client.search("", {
      filter: toFilterExpressions(draft),
      facets: facetIds,
      limit: 0,
    })
    return result.estimatedTotalHits
  }

  return (
    <>
      <ProductFilters
        className="max-md:hidden"
        facets={facets}
        value={value}
        onValueChange={onValueChange}
        resultCount={response.estimatedTotalHits}
      />
      <div className="md:hidden">
        <FilterSheet
          facets={facets}
          value={value}
          onValueChange={onValueChange}
          resultCount={response.estimatedTotalHits}
          onCount={count}
        />
      </div>
    </>
  )
}

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.

"use client"

import type { ReactNode } from "react"
import { usePathname, useRouter, useSearchParams } from "next/navigation"

import { ActiveFilters } from "@/components/blocks/product-filters/active-filters"
import {
  filtersFromSearchParams,
  filtersToSearchParams,
  type Facet,
  type FilterState,
} from "@/components/blocks/product-filters/facets"
import { LoadMore } from "@/components/blocks/product-filters/load-more"
import { ProductFilters } from "@/components/blocks/product-filters/product-filters"

const pageSize = 24

export function UrlFilters({
  facets,
  total,
  children,
}: {
  facets: Facet[]
  total: number
  children: (state: FilterState, shown: number) => ReactNode
}) {
  const router = useRouter()
  const pathname = usePathname()
  const searchParams = useSearchParams()
  const filters = filtersFromSearchParams(facets, searchParams)
  const page = Math.max(1, Number(searchParams.get("page")) || 1)

  const navigate = (next: FilterState, nextPage = 1) => {
    const params = filtersToSearchParams(facets, next, new URLSearchParams(searchParams))
    if (nextPage > 1) params.set("page", String(nextPage))
    else params.delete("page")
    router.replace(`${pathname}?${params}`, { scroll: false })
  }

  return (
    <div className="grid gap-10 md:grid-cols-[15rem_minmax(0,1fr)]">
      <ProductFilters facets={facets} value={filters} onValueChange={navigate} resultCount={total} />
      <div className="flex flex-col gap-5">
        <ActiveFilters facets={facets} value={filters} onValueChange={navigate} />
        {children(filters, Math.min(total, page * pageSize))}
        <LoadMore
          shown={page * pageSize}
          total={total}
          pageSize={pageSize}
          onLoadMore={() => navigate(filters, page + 1)}
        />
      </div>
    </div>
  )
}

Anatomie

Les parties à composer, de l’extérieur vers l’intérieur.

PartieDescription
ProductFiltersThe desktop sidebar: collapsible facet groups that apply live.
FilterSheetA bottom sheet with the same facets, staged changes and a Show results footer.
ActiveFiltersRemovable chips for every applied value, with Clear all.
ListingToolbarThe result count, the sort select, grid size and a slot for the mobile Filters button.
LoadMoreShowing 24 of 128, a progress bar and Load more, with loading and retry states.
FilterEmptyThe no-results state, with the chips to remove and Clear all.
applyFilters, computeFacetCountsFilter items and recount every facet against the other facets.
filtersToSearchParams, filtersFromSearchParamsRead and write the filters as URL search params, dropping unknown values.

ProductFilters

Also takes div props.

PropTypePar défaut
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.
number6
swatchVisibleValuesSwatches shown before Show more.
number12
searchThresholdList facets with this many values or more get a search field.
number15
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>–
PropTypePar défaut
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.

PropTypePar défaut
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.
booleantrue
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.

PropTypePar défaut
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.

PropTypePar défaut
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>–
PropTypePar défaut
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[]–
ToucheAction
TabMoves through facet headings, values, chips and the toolbar.
EnterSpaceExpands or collapses a facet group, or toggles a value.
ArrowLeftArrowRightMoves a price thumb by one step, or moves between rating options.
EnterApplies a typed minimum or maximum price.
EscapeClears a facet search, or closes the filter sheet without applying it.
BackspaceDeleteRemoves 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.

Construit avec

Les composants HextaUI gratuits dont Product Filters est constitué. Chacun s’installe séparément.

Code

11 fichiers, ajoutés à components/blocks/product-filters.