---
name: category-filter
title: Category Filter
url: https://ui.frontic.com/components/templates/category-filter.md
tier: templates
install: npx @frontic/ui add category-filter
files:
  - components/templates/category-filter/CategoryFilter.vue
  - components/templates/category-filter/index.ts
  - components/templates/category-filter/types.ts
registryDependencies:
  - badge
  - button
  - collapsible
  - empty
  - filter
  - filter-model
  - product-card-basic
  - scroll-area
  - select
  - separator
  - sheet
  - tag-group
  - use-filter-state
dependencies:
  - "@lucide/vue"
---

# Category Filter

A category's products with filters in collapsible sidebar sections, sorting, badges for the active filters, and a load-more button.

## Install

```sh
npx @frontic/ui add category-filter
```

Built from: [Badge](https://ui.frontic.com/components/ui/badge.md), [Button](https://ui.frontic.com/components/ui/button.md), [Collapsible](https://ui.frontic.com/components/ui/collapsible.md), [Empty](https://ui.frontic.com/components/ui/empty.md), [Filter](https://ui.frontic.com/components/ui/filter.md), [Filter Model](https://ui.frontic.com/lib/filter-model.md), [Product Card Basic](https://ui.frontic.com/components/sections/product-card-basic.md), [Scroll Area](https://ui.frontic.com/components/ui/scroll-area.md), [Select](https://ui.frontic.com/components/ui/select.md), [Separator](https://ui.frontic.com/components/ui/separator.md), [Sheet](https://ui.frontic.com/components/ui/sheet.md), [Tag Group](https://ui.frontic.com/components/ui/tag-group.md), [Use Filter State](https://ui.frontic.com/composables/use-filter-state.md)

npm packages: `@lucide/vue`

## Examples

```vue
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { CategoryFilter, type Product, type UiFilter, type UiFilterValue, type UiSort } from '@/components/templates/category-filter'

const image = (name: string) => `https://static.frontic.com/cdn-cgi/image/width=640,format=auto/ui/products/${name}.jpg`

// The demo shop's men's jackets: catalog names, prices, colours and images. Each colour of a product is a variant.
const JACKETS = [
  {
    name: 'Summit Series Winter Parka',
    price: { amount: 13200, currency: 'EUR', ref: 15900 },
    rating: 4.7,
    reviewCount: 212,
    variants: [{ slug: 'summit-series-winter-parka-57dec9f4', colour: 'Charcoal', hex: '#4a4a47', image: 'summit-series-winter-parka' }],
  },
  {
    name: 'Glacier Explorer',
    price: { amount: 9400, currency: 'EUR' },
    rating: 4.4,
    reviewCount: 96,
    variants: [{ slug: 'glacier-explorer-53fb49de', colour: 'Slate blue', hex: '#5f7680', image: 'glacier-explorer' }],
  },
  {
    name: 'Winter Cliffs Hiking Jacket',
    price: { amount: 5200, currency: 'EUR' },
    rating: 4.1,
    reviewCount: 58,
    variants: [{ slug: 'winter-cliffs-hiking-jacket-4d851b8e', colour: 'Ochre', hex: '#c08a3c', image: 'winter-cliffs-hiking-jacket' }],
  },
  {
    name: 'Frost Cruiser Softshell',
    price: { amount: 10700, currency: 'EUR' },
    rating: 4.6,
    reviewCount: 143,
    variants: [{ slug: 'frost-cruiser-softshell-4d57b624', colour: 'Steel grey', hex: '#8e98a4', image: 'frost-cruiser-softshell' }],
  },
  {
    name: 'Glacier Hiker Coat',
    price: { amount: 8400, currency: 'EUR', ref: 10500 },
    rating: 4.3,
    reviewCount: 77,
    variants: [{ slug: 'glacierhiker-coat-4cbce0c8', colour: 'Dark olive', hex: '#55573f', image: 'glacier-hiker-coat' }],
  },
  {
    name: 'Summit Strider',
    price: { amount: 8100, currency: 'EUR' },
    rating: 4.0,
    reviewCount: 41,
    variants: [{ slug: 'summit-strider-4a291024', colour: 'Saffron', hex: '#e5a63c', image: 'summit-strider' }],
  },
  {
    name: 'Polar Light Ski Jacket',
    price: { amount: 7900, currency: 'EUR' },
    rating: 4.8,
    reviewCount: 187,
    variants: [{ slug: 'polarlicht-ski-jacket-476160a6', colour: 'Dusty pink', hex: '#d6aaa6', image: 'polar-light-ski-jacket' }],
  },
  {
    name: 'Glacier Fever Functional Jacket',
    price: { amount: 6500, currency: 'EUR' },
    rating: 4.2,
    reviewCount: 63,
    variants: [{ slug: 'glacier-fever-functional-jacket-476c5729', colour: 'Mustard', hex: '#cf9e42', image: 'glacier-fever-functional-jacket' }],
  },
  {
    name: 'Arctic Fox Winter Jacket',
    price: { amount: 6100, currency: 'EUR', ref: 7900 },
    rating: 4.5,
    reviewCount: 119,
    variants: [{ slug: 'polarfuchs-winter-jacket-47b6cbf5', colour: 'Light grey', hex: '#d0d0ce', image: 'arctic-fox-winter-jacket' }],
  },
  {
    name: 'Arctic Mission Jacket',
    price: { amount: 8700, currency: 'EUR' },
    rating: 4.4,
    reviewCount: 88,
    variants: [{ slug: 'arktische-mission-jacket-45628700', colour: 'Dark brown', hex: '#4d3e34', image: 'arctic-mission-jacket' }],
  },
  {
    name: 'Blizzard Defender',
    price: { amount: 12600, currency: 'EUR' },
    rating: 4.9,
    reviewCount: 264,
    variants: [
      { slug: 'blizzard-defender-455fb5a3', colour: 'Aubergine', hex: '#3d3347', image: 'blizzard-defender-aubergine' },
      { slug: 'blizzard-defender-41d5aa85', colour: 'Dusty rose', hex: '#b98a87', image: 'blizzard-defender-dusty-rose' },
      { slug: 'blizzard-defender-393a11b2', colour: 'Salmon', hex: '#dc967b', image: 'blizzard-defender-salmon' },
    ],
  },
  {
    name: 'High Altitude Trekking Jacket',
    price: { amount: 6200, currency: 'EUR' },
    rating: 3.9,
    reviewCount: 35,
    variants: [{ slug: 'hoehenrausch-trekking-jacket-3fc839ea', colour: 'Olive', hex: '#a09d5c', image: 'high-altitude-trekking-jacket' }],
  },
  {
    name: 'Arctic Fox Hybrid Coat',
    price: { amount: 6800, currency: 'EUR' },
    rating: 4.3,
    reviewCount: 72,
    variants: [{ slug: 'arctic-fox-hybrid-coat-3f543a88', colour: 'Terracotta', hex: '#c4735f', image: 'arctic-fox-hybrid-coat' }],
  },
  {
    name: 'Summit Stormer Parka',
    price: { amount: 5700, currency: 'EUR' },
    rating: 4.1,
    reviewCount: 49,
    variants: [{ slug: 'gipfelstuermer-parka-3dcb8f45', colour: 'Amber', hex: '#e2a240', image: 'summit-stormer-parka' }],
  },
  {
    name: 'Mountaineer Hybrid Jacket',
    price: { amount: 9100, currency: 'EUR' },
    rating: 4.6,
    reviewCount: 131,
    variants: [{ slug: 'mountaineer-hybrid-jacket-3d91f9e2', colour: 'Charcoal', hex: '#4a4a47', image: 'mountaineer-hybrid-jacket' }],
  },
  {
    name: 'Aurora Functional Jacket',
    price: { amount: 7400, currency: 'EUR' },
    rating: 4.2,
    reviewCount: 54,
    variants: [{ slug: 'nordlichter-functional-jacket-3b5b93cb', colour: 'Rust', hex: '#b0593f', image: 'aurora-functional-jacket' }],
  },
  {
    name: 'Frost Escape Parka',
    price: { amount: 14900, currency: 'EUR' },
    rating: 4.8,
    reviewCount: 236,
    variants: [{ slug: 'frost-escape-parka-3abd4b58', colour: 'Sun yellow', hex: '#e5ad40', image: 'frost-escape-parka' }],
  },
  {
    name: 'Alpine Trekker',
    price: { amount: 12600, currency: 'EUR' },
    rating: 4.7,
    reviewCount: 158,
    variants: [{ slug: 'alpine-trekker-37813563', colour: 'Stone grey', hex: '#9c9c9a', image: 'alpine-trekker' }],
  },
  {
    name: 'Arctic Mission Parka',
    price: { amount: 11980, currency: 'EUR' },
    rating: 4.5,
    reviewCount: 102,
    variants: [{ slug: 'arktische-mission-jacket-397a17fd', colour: 'Burnt orange', hex: '#c8704f', image: 'arctic-mission-parka' }],
  },
]
type Jacket = (typeof JACKETS)[number]

// Every men's jacket comes in all five sizes.
const SIZES = ['S', 'M', 'L', 'XL', 'XXL']
const VARIANTS = JACKETS.flatMap((jacket) => jacket.variants)
const COLOURS = [...new Set(VARIANTS.map((variant) => variant.colour))].sort()
const swatchColors = Object.fromEntries(VARIANTS.map((variant) => [variant.colour.toLowerCase(), variant.hex]))

const PAGE = 12
const euros = JACKETS.map((jacket) => jacket.price.amount / 100)
const PRICE = { min: Math.floor(Math.min(...euros) / 10) * 10, max: Math.ceil(Math.max(...euros) / 10) * 10 }

const state = reactive({
  colours: new Set<string>(),
  sizes: new Set<string>(['M']),
  price: {} as { from?: number; to?: number },
  search: '',
  sort: 'default',
})
const shown = ref(PAGE)

const matches = (jacket: Jacket, skip?: 'colour') =>
  (skip === 'colour' || !state.colours.size || jacket.variants.some((variant) => state.colours.has(variant.colour))) &&
  jacket.price.amount / 100 >= (state.price.from ?? PRICE.min) &&
  jacket.price.amount / 100 <= (state.price.to ?? PRICE.max) &&
  jacket.name.toLowerCase().includes(state.search.trim().toLowerCase())

const SORTS: Record<string, (a: Jacket, b: Jacket) => number> = {
  'price.amount:asc': (a, b) => a.price.amount - b.price.amount,
  'price.amount:desc': (a, b) => b.price.amount - a.price.amount,
  'name:asc': (a, b) => a.name.localeCompare(b.name),
}

const results = computed(() => {
  const found = JACKETS.filter((jacket) => matches(jacket))
  return SORTS[state.sort] ? [...found].sort(SORTS[state.sort]) : found
})

const filters = computed<UiFilter[]>(() => [
  {
    key: 'colour',
    label: 'Colour',
    type: 'color',
    options: COLOURS.map((colour) => {
      const count = JACKETS.filter((jacket) => matches(jacket, 'colour') && jacket.variants.some((variant) => variant.colour === colour)).length
      const selected = state.colours.has(colour)
      return { option: colour, value: colour, count, selected, disabled: !count && !selected }
    }),
  },
  {
    key: 'size',
    label: 'Size',
    type: 'default',
    options: SIZES.map((size) => ({ option: size, value: size, count: results.value.length, selected: state.sizes.has(size), disabled: !results.value.length && !state.sizes.has(size) })),
  },
  { key: 'price', label: 'Price', type: 'range', options: [], data: { total: { ...PRICE, total: JACKETS.length }, result: { ...PRICE, total: results.value.length } } },
])

const activeFilters = computed(() => (state.price.from !== undefined || state.price.to !== undefined ? { price: state.price } : {}))

const sortOptions: UiSort[] = [
  { key: 'default', label: 'Relevance', value: 'default' },
  { key: 'price.amount:asc', label: 'Price: Low to High', value: 'price.amount:asc' },
  { key: 'price.amount:desc', label: 'Price: High to Low', value: 'price.amount:desc' },
  { key: 'name:asc', label: 'Name: A–Z', value: 'name:asc' },
]

const products = computed<Product[]>(() =>
  results.value.slice(0, shown.value).map((jacket) => {
    const variant = jacket.variants.find((v) => state.colours.has(v.colour)) ?? jacket.variants[0]!
    return {
      key: variant.slug,
      name: jacket.name,
      price: jacket.price,
      cover: { src: image(variant.image) },
      rating: jacket.rating,
      reviewCount: jacket.reviewCount,
      ...(jacket.variants.length > 1
        ? {
            siblings: {
              items: jacket.variants.map((v) => ({
                key: v.slug,
                name: `${jacket.name}, ${v.colour}`,
                cover: { src: image(v.image) },
                colors: { exactColorHex: v.hex, exactColorName: v.colour },
              })),
            },
          }
        : {}),
    }
  }),
)

const productsLabel = (count: number) => `${count} ${count === 1 ? 'product' : 'products'}`
const showLabel = (count: number) => `Show ${productsLabel(count)}`

const setOf = (field: string) => (field === 'colour' ? state.colours : field === 'size' ? state.sizes : undefined)

function addFilter(field: string, value: UiFilterValue) {
  const set = setOf(field)
  if (set) set.add(String(value))
  else if (field === 'price' && typeof value === 'object') state.price = value
  shown.value = PAGE
}

function removeFilter(field: string, value?: string) {
  const set = setOf(field)
  if (set) {
    if (value === undefined) set.clear()
    else set.delete(value)
  } else if (field === 'price') state.price = {}
  shown.value = PAGE
}

function reset() {
  state.colours.clear()
  state.sizes.clear()
  state.price = {}
  state.search = ''
  state.sort = 'default'
  shown.value = PAGE
}
</script>

<template>
  <CategoryFilter
    :filters="filters"
    :active-filters="activeFilters"
    :sort-options="sortOptions"
    :active-sorting="state.sort"
    :products="products"
    title="Jackets"
    description="Parkas, softshells and ski jackets for cold days on the mountain and in town, every one in sizes S to XXL."
    :total-results="results.length"
    :results-label="productsLabel"
    :show-results-label="showLabel"
    :swatch-colors="swatchColors"
    @add-filter="addFilter"
    @remove-filter="removeFilter"
    @sort-result="(sort?: string) => (state.sort = sort ?? 'default')"
    @reset="reset"
    @load-more="shown += PAGE"
  />
</template>
```

## API reference

### CategoryFilter

Shows a category's products with filters, sorting and a load-more button. Renders a `<div>`.

Styling: `category-filter-heading`, which extends [`heading`](https://ui.frontic.com/DESIGN.md#heading); [`part-title`](https://ui.frontic.com/DESIGN.md#part-title); [`hint`](https://ui.frontic.com/DESIGN.md#hint); [`clear-link`](https://ui.frontic.com/DESIGN.md#clear-link). Change them there and every part that extends them follows.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `filters` | `UiFilter[]` | `[]` | The filters to offer; `type` picks the control: `boolean`, `rating`, `color`, `range` or an option list. |
| `activeFilters` | `Record<string, unknown>` | `{}` | Applied values by filter key for switch, rating and range filters; list options carry `selected` themselves. |
| `sortOptions` | `UiSort[]` | `[]` | The sort orders in the sort select. |
| `activeSorting` | `string` | `'default'` | The `value` of the selected sort option. |
| `totalResults` | `number` | `0` | Number of products matching the filters; the load-more button shows while fewer are loaded. |
| `title` | `string` | `''` | Shown as the page heading. |
| `description` | `string` | `''` | Shown under the page heading. |
| `products` | `Product[]` | `[]` | The products loaded so far, shown as cards. |
| `currency` | `string` | `'EUR'` | ISO 4217 code the price filter shows its range in. |
| `locale` | `string` |  | BCP 47 locale the prices are formatted in; the ConfigProvider's when left out. |
| `resultsLabel` | `((count: number) => string)`: `(count: number): string` | ``${count} ${count === 1 ? 'result' : 'results'}`` | Builds the results count above the grid from `totalResults`. |
| `filtersLabel` | `string` | `'Filters'` | Shown on the button that opens the filter sheet on small screens, and as the sidebar's screen-reader heading. |
| `sheetTitle` | `string` | `'Filters'` | Shown as the filter sheet's heading. |
| `sortPlaceholder` | `string` | `'Sort by'` | Shown in the sort select while no option is selected. |
| `resetLabel` | `string` | `'Reset'` | Shown on the filter sheet's buttons that clear every filter and the sorting. |
| `showResultsLabel` | `((count: number) => string)`: `(count: number): string` | ``Show Results (${count})`` | Builds the label of the button that closes the filter sheet from `totalResults`. |
| `removeLabel` | `((label: string) => string)`: `(label: string): string` | ``Remove ${label}`` | Builds the screen-reader name of an active filter's remove button from the option's label. |
| `activeFiltersLabel` | `string` | `'Active filters'` | Names the list of active filters for screen readers. |
| `clearAllLabel` | `string` | `'Clear all'` | Shown on the link next to the active filters and on the empty state's button; both clear every filter. |
| `emptyTitle` | `string` | `'No products match these filters'` | Shown as the heading when no product matches the filters. |
| `emptyText` | `string` | `'Remove a filter or clear them all to see more.'` | Shown under the heading when no product matches the filters. |
| `loadMoreLabel` | `string` | `'Load more'` | Shown on the button under the grid that loads more products. |
| `swatchColors` | `Record<string, string>` | `{ ...DEFAULT_SWATCH_COLORS }` | Swatch colour per colour-filter value, keyed lowercase. |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |

| Event | Payload | Description |
| --- | --- | --- |
| `reset` |  | Fires when the shopper resets all filters. |
| `addFilter` | `field: string, value: UiFilterValue` | Fires with the filter key and its new value when the shopper selects an option, turns on a switch or sets a range. |
| `removeFilter` | `field: string, value?: string \| undefined` | Fires with the filter key, plus the option value for a single option, when the shopper deselects or clears a filter. |
| `sortResult` | `sortBy?: string \| undefined` | Fires with the chosen sort key, or undefined for the default order, when the shopper changes the sorting. |
| `loadMore` |  | Fires when the shopper clicks the load-more button. |

## Exports

```ts
import { type Product, type UiFilter, type UiFilterOption, type UiFilterStats, type UiFilterValue, type UiSort } from '@/components/templates/category-filter'
```

- `Product`: from [Product Card Basic](https://ui.frontic.com/components/sections/product-card-basic.md)
- `UiFilter`, `UiFilterOption`, `UiFilterStats`, `UiFilterValue`, `UiSort`: from [Filter Model](https://ui.frontic.com/lib/filter-model.md)

## Styling

| Part | Recipe |
| --- | --- |
| `default` | [`category-filter`](https://ui.frontic.com/DESIGN.md#category-filter) |
| `heading` | [`category-filter-heading`](https://ui.frontic.com/DESIGN.md#category-filter-heading) |
| `description` | [`category-filter-description`](https://ui.frontic.com/DESIGN.md#category-filter-description) |
| `part-title` | [`part-title`](https://ui.frontic.com/DESIGN.md#part-title) |
| `hint` | [`hint`](https://ui.frontic.com/DESIGN.md#hint) |
| `clear-link` | [`clear-link`](https://ui.frontic.com/DESIGN.md#clear-link) |
| `focus-ring` | [`focus-ring`](https://ui.frontic.com/DESIGN.md#focus-ring) |

States with a look of their own: hover and focus.

## Source

`components/templates/category-filter/CategoryFilter.vue`

```vue
<script lang="ts">
/** Shows a category's products with filters, sorting and a load-more button. */
export default {}
</script>

<script setup lang="ts">
import { SlidersHorizontalIcon, ChevronDownIcon } from '@lucide/vue'

import type { HTMLAttributes } from 'vue'
import type { Product, UiFilter, UiFilterValue, UiSort } from './types'
import { ref } from 'vue'
import { useFilterState } from '@library/composables/use-filter-state'
import { activeBoolean, activeRange, activeRating, DEFAULT_SWATCH_COLORS } from '@library/lib/filter-model'
import { Badge } from '@library/components/ui/badge'
import { TagGroup, TagGroupItem, TagGroupItemDelete, TagGroupItemText } from '@library/components/ui/tag-group'
import { Button } from '@library/components/ui/button'
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from '@library/components/ui/empty'
import { Filter, FilterGroup, FilterList, FilterPrice, FilterRating, FilterReset, FilterSwatch, FilterSwitch } from '@library/components/ui/filter'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@library/components/ui/collapsible'
import { ScrollArea } from '@library/components/ui/scroll-area'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@library/components/ui/select'
import { Separator } from '@library/components/ui/separator'
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from '@library/components/ui/sheet'
import { ProductCardBasic } from '@library/components/sections/product-card-basic'

const props = withDefaults(
  defineProps<{
    /** The filters to offer; `type` picks the control: `boolean`, `rating`, `color`, `range` or an option list. */
    filters?: UiFilter[]
    /** Applied values by filter key for switch, rating and range filters; list options carry `selected` themselves. */
    activeFilters?: Record<string, unknown>
    /** The sort orders in the sort select. */
    sortOptions?: UiSort[]
    /** The `value` of the selected sort option. */
    activeSorting?: string
    /** Number of products matching the filters; the load-more button shows while fewer are loaded. */
    totalResults?: number
    /** Shown as the page heading. */
    title?: string
    /** Shown under the page heading. */
    description?: string
    /** The products loaded so far, shown as cards. */
    products?: Product[]
    /** ISO 4217 code the price filter shows its range in. */
    currency?: string
    /** BCP 47 locale the prices are formatted in; the ConfigProvider's when left out. */
    locale?: string
    /** Builds the results count above the grid from `totalResults`. */
    resultsLabel?: (count: number) => string
    /** Shown on the button that opens the filter sheet on small screens, and as the sidebar's screen-reader heading. */
    filtersLabel?: string
    /** Shown as the filter sheet's heading. */
    sheetTitle?: string
    /** Shown in the sort select while no option is selected. */
    sortPlaceholder?: string
    /** Shown on the filter sheet's buttons that clear every filter and the sorting. */
    resetLabel?: string
    /** Builds the label of the button that closes the filter sheet from `totalResults`. */
    showResultsLabel?: (count: number) => string
    /** Builds the screen-reader name of an active filter's remove button from the option's label. */
    removeLabel?: (label: string) => string
    /** Names the list of active filters for screen readers. */
    activeFiltersLabel?: string
    /** Shown on the link next to the active filters and on the empty state's button; both clear every filter. */
    clearAllLabel?: string
    /** Shown as the heading when no product matches the filters. */
    emptyTitle?: string
    /** Shown under the heading when no product matches the filters. */
    emptyText?: string
    /** Shown on the button under the grid that loads more products. */
    loadMoreLabel?: string
    /** Swatch colour per colour-filter value, keyed lowercase. */
    swatchColors?: Record<string, string>
    class?: HTMLAttributes['class']
  }>(),
  {
    filters: () => [],
    activeFilters: () => ({}),
    sortOptions: () => [],
    activeSorting: 'default',
    totalResults: 0,
    title: '',
    description: '',
    products: () => [],
    currency: 'EUR',
    resultsLabel: (count: number) => `${count} ${count === 1 ? 'result' : 'results'}`,
    filtersLabel: 'Filters',
    sheetTitle: 'Filters',
    sortPlaceholder: 'Sort by',
    resetLabel: 'Reset',
    showResultsLabel: (count: number) => `Show Results (${count})`,
    removeLabel: (label: string) => `Remove ${label}`,
    activeFiltersLabel: 'Active filters',
    clearAllLabel: 'Clear all',
    emptyTitle: 'No products match these filters',
    emptyText: 'Remove a filter or clear them all to see more.',
    loadMoreLabel: 'Load more',
    swatchColors: () => ({ ...DEFAULT_SWATCH_COLORS }),
  },
)

const emit = defineEmits<{
  /** Fires with the filter key and its new value when the shopper selects an option, turns on a switch or sets a range. */
  addFilter: [field: string, value: UiFilterValue]
  /** Fires with the filter key, plus the option value for a single option, when the shopper deselects or clears a filter. */
  removeFilter: [field: string, value?: string]
  /** Fires with the chosen sort key, or undefined for the default order, when the shopper changes the sorting. */
  sortResult: [sortBy?: string]
  /** Fires when the shopper resets all filters. */
  reset: []
  /** Fires when the shopper clicks the load-more button. */
  loadMore: []
}>()

const { localFilters, localSorting, activeCount, activeBadges, swatches, selectedCount, toggleFilterOption, handleToggle, handleRating, handleRange, handleSort, removeTags, resetAll } =
  useFilterState(
    {
      filters: () => props.filters,
      activeFilters: () => props.activeFilters,
      activeSorting: () => props.activeSorting,
      swatchColors: () => props.swatchColors,
    },
    {
      add: (field, value) => emit('addFilter', field, value),
      remove: (field, value) => emit('removeFilter', field, value),
      sort: (sortBy) => emit('sortResult', sortBy),
      reset: () => emit('reset'),
    },
  )

const mobileSheetOpen = ref(false)
</script>

<template>
  <div :class="['fui-category-filter @container', props.class]">
    <div>
      <div class="px-4 py-12 @2xl:px-6 @5xl:px-8">
        <div class="mx-auto max-w-7xl">
          <h1 data-slot="category-filter-heading" class="fui-category-filter-heading">
            {{ title }}
          </h1>
          <p data-slot="category-filter-description" class="fui-category-filter-description">
            {{ description }}
          </p>
        </div>
      </div>
      <Separator />
    </div>

    <div class="mx-auto max-w-7xl px-4 @2xl:px-6 @5xl:px-8">
      <div>
        <div class="flex items-center justify-between py-4">
          <p data-slot="hint" class="fui-hint">
            {{ resultsLabel(totalResults) }}
          </p>

          <div class="flex items-center gap-3">
            <div class="hidden items-center gap-2 @2xl:flex">
              <div class="w-42.5 shrink-0">
                <Select :model-value="localSorting" @update:model-value="handleSort">
                  <SelectTrigger class="w-full">
                    <SelectValue :placeholder="sortPlaceholder" />
                  </SelectTrigger>
                  <SelectContent>
                    <SelectItem v-for="opt in sortOptions" :key="opt.key" :value="opt.value">{{ opt.label }}</SelectItem>
                  </SelectContent>
                </Select>
              </div>
            </div>

            <Sheet v-model:open="mobileSheetOpen">
              <SheetTrigger as-child>
                <Button variant="outline" color="neutral" size="sm" class="@5xl:hidden">
                  <SlidersHorizontalIcon data-icon />
                  {{ filtersLabel }}
                  <Badge v-if="activeCount > 0" size="sm" class="ms-0.5">{{ activeCount }}</Badge>
                </Button>
              </SheetTrigger>
              <SheetContent side="right">
                <SheetHeader>
                  <SheetTitle>{{ sheetTitle }}</SheetTitle>
                </SheetHeader>
                <ScrollArea class="flex-1">
                  <Filter :reset="resetAll" class="flex flex-col gap-2 px-4 py-4">
                    <Select :model-value="localSorting" @update:model-value="handleSort">
                      <SelectTrigger class="w-full">
                        <SelectValue :placeholder="sortPlaceholder" />
                      </SelectTrigger>
                      <SelectContent>
                        <SelectItem v-for="opt in sortOptions" :key="opt.key" :value="opt.value">{{ opt.label }}</SelectItem>
                      </SelectContent>
                    </Select>

                    <template v-for="filter in localFilters" :key="filter.key">
                      <FilterSwitch v-if="filter.type === 'boolean'" :value="activeBoolean(activeFilters, filter.key)" :label="filter.label" @change="handleToggle(filter.key, $event)" />
                      <FilterGroup v-else-if="filter.type === 'rating'" type="collapsible" :label="filter.label">
                        <FilterRating :value="activeRating(activeFilters, filter.key)" :max="5" @change="handleRating(filter.key, $event)" />
                      </FilterGroup>
                      <FilterGroup v-else-if="filter.type === 'color'" type="collapsible" :label="filter.label" :active-count="selectedCount(filter)" :default-open="!!selectedCount(filter)">
                        <FilterSwatch :options="swatches(filter)" @toggle="toggleFilterOption(filter.key, $event)" />
                      </FilterGroup>
                      <FilterGroup v-else-if="filter.type === 'range'" type="collapsible" :label="filter.label">
                        <FilterPrice
                          :currency="currency"
                          :locale="locale"
                          :precision="0"
                          :min="filter.data?.total.min ?? 0"
                          :max="filter.data?.total.max ?? 1000"
                          :step="10"
                          :value="{
                            from: activeRange(activeFilters, filter.key).from ?? filter.data?.total.min ?? 0,
                            to: activeRange(activeFilters, filter.key).to ?? filter.data?.total.max ?? 1000,
                          }"
                          @change="handleRange(filter.key, $event)"
                        />
                      </FilterGroup>
                      <FilterGroup v-else type="collapsible" :label="filter.label" :active-count="selectedCount(filter)" :default-open="!!selectedCount(filter)">
                        <FilterList :options="filter.options" @toggle="toggleFilterOption(filter.key, $event)" />
                      </FilterGroup>
                    </template>

                    <FilterReset :label="resetLabel" />
                  </Filter>
                </ScrollArea>
                <div>
                  <Separator />
                  <SheetFooter class="flex-row">
                    <Button variant="outline" color="neutral" class="flex-1" :disabled="activeCount === 0" @click="resetAll">{{ resetLabel }}</Button>
                    <Button class="flex-1" @click="mobileSheetOpen = false">{{ showResultsLabel(totalResults) }}</Button>
                  </SheetFooter>
                </div>
              </SheetContent>
            </Sheet>
          </div>
        </div>
        <Separator />
      </div>

      <div v-if="activeBadges.length > 0">
        <div class="flex flex-wrap items-center gap-2 py-3">
          <TagGroup :aria-label="activeFiltersLabel" @remove="removeTags">
            <TagGroupItem v-for="badge in activeBadges" :key="`${badge.field}-${badge.value}`" :value="`${badge.field}-${badge.value}`">
              <TagGroupItemText>{{ badge.label }}</TagGroupItemText>
              <TagGroupItemDelete :aria-label="removeLabel(badge.label)" :aria-labelledby="undefined" />
            </TagGroupItem>
          </TagGroup>
          <button type="button" data-slot="clear-link" class="fui-clear-link outline-none focus-visible:fui-focus-ring" @click="resetAll">
            {{ clearAllLabel }}
          </button>
        </div>
        <Separator />
      </div>

      <div class="pt-8 pb-16 @5xl:grid @5xl:grid-cols-4 @5xl:gap-x-8">
        <aside class="hidden @5xl:block">
          <h2 class="sr-only">{{ filtersLabel }}</h2>
          <Filter :reset="resetAll">
            <template v-for="(filter, index) in localFilters" :key="filter.key">
              <Separator v-if="index > 0" />
              <div v-if="filter.type === 'boolean'" class="py-6 first:pt-0 last:pb-0">
                <FilterSwitch :value="activeBoolean(activeFilters, filter.key)" :label="filter.label" @change="handleToggle(filter.key, $event)" />
              </div>
              <Collapsible v-else :default-open="true" class="py-6 first:pt-0 last:pb-0">
                <CollapsibleTrigger as-child>
                  <button type="button" data-slot="part-title" class="fui-part-title group flex w-full items-center justify-between outline-none focus-visible:fui-focus-ring">
                    <span class="flex items-center gap-2">
                      {{ filter.label }}
                      <Badge v-if="selectedCount(filter) && filter.type !== 'rating' && filter.type !== 'range'" size="sm">{{ selectedCount(filter) }}</Badge>
                    </span>
                    <span data-slot="hint" class="fui-hint flex">
                      <ChevronDownIcon class="size-4 transition-transform group-aria-expanded:rotate-180" />
                    </span>
                  </button>
                </CollapsibleTrigger>
                <CollapsibleContent class="pt-4">
                  <FilterRating v-if="filter.type === 'rating'" :value="activeRating(activeFilters, filter.key)" :max="5" @change="handleRating(filter.key, $event)" />
                  <FilterSwatch v-else-if="filter.type === 'color'" :options="swatches(filter)" @toggle="toggleFilterOption(filter.key, $event)" />
                  <FilterPrice
                    v-else-if="filter.type === 'range'"
                    :aria-label="filter.label"
                    :currency="currency"
                    :locale="locale"
                    :precision="0"
                    :min="filter.data?.total.min ?? 0"
                    :max="filter.data?.total.max ?? 1000"
                    :step="10"
                    :value="{
                      from: activeRange(activeFilters, filter.key).from ?? filter.data?.total.min ?? 0,
                      to: activeRange(activeFilters, filter.key).to ?? filter.data?.total.max ?? 1000,
                    }"
                    @change="handleRange(filter.key, $event)"
                  />
                  <FilterList v-else :options="filter.options" @toggle="toggleFilterOption(filter.key, $event)" />
                </CollapsibleContent>
              </Collapsible>
            </template>
          </Filter>
        </aside>

        <div class="mt-6 @5xl:col-span-3 @5xl:mt-0">
          <div v-if="products.length" class="grid grid-cols-1 gap-4 @sm:grid-cols-2 @3xl:grid-cols-3">
            <ProductCardBasic v-for="(product, index) in products" :key="product.key ?? index" :product="product" :locale="locale" />
          </div>
          <Empty v-else>
            <EmptyHeader>
              <EmptyTitle>{{ emptyTitle }}</EmptyTitle>
              <EmptyDescription>{{ emptyText }}</EmptyDescription>
            </EmptyHeader>
            <EmptyContent>
              <Button variant="outline" color="neutral" @click="resetAll">{{ clearAllLabel }}</Button>
            </EmptyContent>
          </Empty>

          <div v-if="products.length < totalResults" class="mt-10 flex justify-center">
            <Button variant="outline" color="neutral" @click="emit('loadMore')">{{ loadMoreLabel }}</Button>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>
```

`components/templates/category-filter/types.ts`

```ts
export type { Product } from '@library/components/sections/product-card-basic'
export type { UiFilter, UiFilterOption, UiFilterStats, UiFilterValue, UiSort } from '@library/lib/filter-model'
```
