---
name: filters
title: Filters
url: https://ui.frontic.com/components/sections/filters.md
tier: sections
install: npx @frontic/ui add filters
files:
  - components/sections/filters/Filters.vue
  - components/sections/filters/index.ts
  - components/sections/filters/types.ts
registryDependencies:
  - accordion
  - badge
  - button
  - card
  - filter
  - price
  - slider
---

# Filters

Filter card with a price range slider, collapsible category, size and colour groups that count their selections, an in-stock switch, a live result count, and clear and show-results actions.

## Install

```sh
npx @frontic/ui add filters
```

Built from: [Accordion](https://ui.frontic.com/components/ui/accordion.md), [Badge](https://ui.frontic.com/components/ui/badge.md), [Button](https://ui.frontic.com/components/ui/button.md), [Card](https://ui.frontic.com/components/ui/card.md), [Filter](https://ui.frontic.com/components/ui/filter.md), [Price](https://ui.frontic.com/components/ui/price.md), [Slider](https://ui.frontic.com/components/ui/slider.md)

## Examples

```vue
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { Filters, type FiltersGroup } from '@/components/sections/filters'

// The demo shop's men's jackets, as the category grid shows them. Blizzard Defender is one product in three colours.
const JACKETS = [
  { name: 'Summit Series Winter Parka', price: { amount: 13200, currency: 'EUR', ref: 15900 }, colours: [{ colour: 'Charcoal', hex: '#4a4a47' }] },
  { name: 'Glacier Explorer', price: { amount: 9400, currency: 'EUR' }, colours: [{ colour: 'Slate blue', hex: '#5f7680' }], inStock: false },
  { name: 'Winter Cliffs Hiking Jacket', price: { amount: 5200, currency: 'EUR' }, colours: [{ colour: 'Ochre', hex: '#c08a3c' }] },
  { name: 'Frost Cruiser Softshell', price: { amount: 10700, currency: 'EUR' }, colours: [{ colour: 'Steel grey', hex: '#8e98a4' }], inStock: false },
  { name: 'Glacier Hiker Coat', price: { amount: 8400, currency: 'EUR', ref: 10500 }, colours: [{ colour: 'Dark olive', hex: '#55573f' }] },
  { name: 'Summit Strider', price: { amount: 8100, currency: 'EUR' }, colours: [{ colour: 'Saffron', hex: '#e5a63c' }] },
  { name: 'Polar Light Ski Jacket', price: { amount: 7900, currency: 'EUR' }, colours: [{ colour: 'Dusty pink', hex: '#d6aaa6' }] },
  { name: 'Glacier Fever Functional Jacket', price: { amount: 6500, currency: 'EUR' }, colours: [{ colour: 'Mustard', hex: '#cf9e42' }] },
  { name: 'Arctic Fox Winter Jacket', price: { amount: 6100, currency: 'EUR', ref: 7900 }, colours: [{ colour: 'Light grey', hex: '#d0d0ce' }] },
  { name: 'Arctic Mission Jacket', price: { amount: 8700, currency: 'EUR' }, colours: [{ colour: 'Dark brown', hex: '#4d3e34' }] },
  {
    name: 'Blizzard Defender',
    price: { amount: 12600, currency: 'EUR' },
    colours: [
      { colour: 'Aubergine', hex: '#3d3347' },
      { colour: 'Dusty rose', hex: '#b98a87' },
      { colour: 'Salmon', hex: '#dc967b' },
    ],
  },
  { name: 'High Altitude Trekking Jacket', price: { amount: 6200, currency: 'EUR' }, colours: [{ colour: 'Olive', hex: '#a09d5c' }] },
  { name: 'Arctic Fox Hybrid Coat', price: { amount: 6800, currency: 'EUR' }, colours: [{ colour: 'Terracotta', hex: '#c4735f' }], inStock: false },
  { name: 'Summit Stormer Parka', price: { amount: 5700, currency: 'EUR' }, colours: [{ colour: 'Amber', hex: '#e2a240' }] },
  { name: 'Mountaineer Hybrid Jacket', price: { amount: 9100, currency: 'EUR' }, colours: [{ colour: 'Charcoal', hex: '#4a4a47' }] },
  { name: 'Aurora Functional Jacket', price: { amount: 7400, currency: 'EUR' }, colours: [{ colour: 'Rust', hex: '#b0593f' }] },
  { name: 'Frost Escape Parka', price: { amount: 14900, currency: 'EUR' }, colours: [{ colour: 'Sun yellow', hex: '#e5ad40' }] },
  { name: 'Alpine Trekker', price: { amount: 12600, currency: 'EUR' }, colours: [{ colour: 'Stone grey', hex: '#9c9c9a' }], inStock: false },
  { name: 'Arctic Mission Parka', price: { amount: 11980, currency: 'EUR' }, colours: [{ colour: 'Burnt orange', hex: '#c8704f' }] },
]
type Jacket = (typeof JACKETS)[number]

const HEX: Record<string, string> = Object.fromEntries(JACKETS.flatMap((jacket) => jacket.colours.map(({ colour, hex }) => [colour, hex])).sort(([a], [b]) => a.localeCompare(b)))

// Every men's jacket comes in all five sizes.
const SIZES = ['S', 'M', 'L', 'XL', 'XXL']

const MIN = Math.floor(Math.min(...JACKETS.map((j) => j.price.amount)) / 1000) * 1000
const MAX = Math.ceil(Math.max(...JACKETS.map((j) => j.price.amount)) / 1000) * 1000

const chosen = reactive({ size: new Set<string>(['M']), colour: new Set<string>() })
const price = ref([MIN, MAX])
const inStock = ref(false)

const priced = (jacket: Jacket) => jacket.price.amount >= (price.value[0] ?? MIN) && jacket.price.amount <= (price.value[1] ?? MAX)
const stocked = (jacket: Jacket) => !inStock.value || jacket.inStock !== false
const coloured = (jacket: Jacket) => !chosen.colour.size || jacket.colours.some(({ colour }) => chosen.colour.has(colour))

const results = computed(() => JACKETS.filter((jacket) => priced(jacket) && stocked(jacket) && coloured(jacket)).length)

const groups = computed<FiltersGroup[]>({
  get: () => [
    {
      value: 'size',
      label: 'Size',
      options: SIZES.map((size) => ({ option: size, value: size, count: results.value, selected: chosen.size.has(size) })),
    },
    {
      value: 'colour',
      label: 'Colour',
      kind: 'swatch' as const,
      options: Object.entries(HEX).map(([colour, hex]) => {
        const count = JACKETS.filter((jacket) => priced(jacket) && stocked(jacket) && jacket.colours.some((c) => c.colour === colour)).length
        const selected = chosen.colour.has(colour)
        return { value: colour, hex, label: colour, count, selected, disabled: !count && !selected }
      }),
    },
  ],
  set: (next: FiltersGroup[]) => {
    for (const group of next) {
      const set = group.value === 'size' ? chosen.size : chosen.colour
      set.clear()
      for (const option of group.options) if (option.selected) set.add(option.value)
    }
  },
})

const productsLabel = (count: number) => `${count} ${count === 1 ? 'product' : 'products'}`
</script>

<template>
  <div class="mx-auto w-full max-w-md">
    <Filters
      v-model:groups="groups"
      v-model:price="price"
      v-model:in-stock="inStock"
      description="Men · Jackets"
      :results="results"
      :price-min="MIN"
      :price-max="MAX"
      :price-step="500"
      :default-open="['price', 'colour']"
      :results-label="productsLabel"
      :show-results-label="(count: number) => `Show ${productsLabel(count)}`"
    />
  </div>
</template>
```

## API reference

### Filters

Shows filter groups with a price range and in-stock switch, and a result count.

Styling: `filters-range-to`, which extends [`hint`](https://ui.frontic.com/DESIGN.md#hint). Change it there and every part that extends it follows.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `title` | `string` | `'Filters'` | The card's heading. |
| `description` | `string` |  | Where the filters apply, such as the department and category; the result count follows it. |
| `results` (required) | `number` |  | How many products the current filters leave. |
| `priceMin` (required) | `number` |  | The low end of the price slider, in minor units. |
| `priceMax` (required) | `number` |  | The high end of the price slider, in minor units. |
| `priceStep` | `number` | `100` | How far one step moves the price slider, in minor units. |
| `currency` | `string` | `'EUR'` | ISO 4217 code the price range is shown in. |
| `precision` | `number` | `2` | Decimal places of the currency; amounts are in its minor unit, so `2` means cents. |
| `defaultOpen` | `string[]` | `['price']` | The accordion items open at first: `price` and any group's value. |
| `priceLabel` | `string` | `'Price'` | Shown on the accordion item that holds the price slider. |
| `priceToLabel` | `string` | `'to'` | Shown between the low and high price of the range. |
| `inStockLabel` | `string` | `'In stock only'` | Shown beside the switch that hides sold-out products. |
| `clearLabel` | `string` | `'Clear all'` | Shown on the button that clears every filter. |
| `resultsLabel` | `((count: number) => string)`: `(count: number): string` | `count === 1 ? '1 result' : `${count} results`` | Builds the result count under the heading from the number of products. |
| `showResultsLabel` | `((count: number) => string)`: `(count: number): string` | `count === 1 ? 'Show 1 result' : `Show ${count} results`` | Builds the text of the button that applies the filters from the number of products. |
| `surface` | `'raised' \| 'bare'` | `'raised'` | Bare drops the edge, shadow and inset, for a section placed inside another Card. |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |
| `groups` | `FiltersGroup[]` | `[]` | The filter groups, one accordion item each, with their options' selection. |
| `price` | `number[]` |  | The chosen price range as `[low, high]` in minor units; the full range when left out. |
| `inStock` | `boolean` | `false` | Whether only products in stock are shown. |

| Event | Payload | Description |
| --- | --- | --- |
| `clear` |  | Fires after the shopper clears all filters, once price, options and in-stock are reset. |
| `update:groups` | `value: FiltersGroup[]` | Fires with the new value when `groups` changes; `v-model:groups` binds it. |
| `update:price` | `value: number[] \| undefined` | Fires with the new value when `price` changes; `v-model:price` binds it. |
| `update:inStock` | `value: boolean` | Fires with the new value when `inStock` changes; `v-model:inStock` binds it. |
| `apply` |  | Fires when the shopper clicks the show-results button to apply the filters. |

| Data attribute | Values | Description |
| --- | --- | --- |
| `data-surface` | `'raised' \| 'bare'` | Carries the `surface` prop. |

## Exports

```ts
import { type FiltersGroup, type FiltersListGroup, type FiltersSwatchGroup } from '@/components/sections/filters'
```

- `FiltersGroup`: `FiltersListGroup \| FiltersSwatchGroup`
- `FiltersListGroup`: below
- `FiltersSwatchGroup`: below

```ts
interface FiltersListGroup {
  value: string
  label: string
  kind?: 'list'
  options: FilterOption[]
}

interface FiltersSwatchGroup {
  value: string
  label: string
  kind: 'swatch'
  options: FilterSwatchOption[]
}
```

## Styling

| Part | Recipe |
| --- | --- |
| `range-to` | [`filters-range-to`](https://ui.frontic.com/DESIGN.md#filters-range-to) |

## Source

`components/sections/filters/Filters.vue`

```vue
<script lang="ts">
/** Shows filter groups with a price range and in-stock switch, and a result count. */
export default {}
</script>

<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { FiltersGroup } from './types'
import { computed } from 'vue'
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@library/components/ui/accordion'
import { Badge } from '@library/components/ui/badge'
import { Button } from '@library/components/ui/button'
import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@library/components/ui/card'
import { Filter, FilterList, FilterSwatch, FilterSwitch } from '@library/components/ui/filter'
import { Price } from '@library/components/ui/price'
import { Slider } from '@library/components/ui/slider'

const props = withDefaults(
  defineProps<{
    /** The card's heading. */
    title?: string
    /** Where the filters apply, such as the department and category; the result count follows it. */
    description?: string
    /** How many products the current filters leave. */
    results: number
    /** The low end of the price slider, in minor units. */
    priceMin: number
    /** The high end of the price slider, in minor units. */
    priceMax: number
    /** How far one step moves the price slider, in minor units. */
    priceStep?: number
    /** ISO 4217 code the price range is shown in. */
    currency?: string
    /** Decimal places of the currency; amounts are in its minor unit, so `2` means cents. */
    precision?: number
    /** The accordion items open at first: `price` and any group's value. */
    defaultOpen?: string[]
    /** Shown on the accordion item that holds the price slider. */
    priceLabel?: string
    /** Shown between the low and high price of the range. */
    priceToLabel?: string
    /** Shown beside the switch that hides sold-out products. */
    inStockLabel?: string
    /** Shown on the button that clears every filter. */
    clearLabel?: string
    /** Builds the result count under the heading from the number of products. */
    resultsLabel?: (count: number) => string
    /** Builds the text of the button that applies the filters from the number of products. */
    showResultsLabel?: (count: number) => string
    /** Bare drops the edge, shadow and inset, for a section placed inside another Card. */
    surface?: 'raised' | 'bare'
    class?: HTMLAttributes['class']
  }>(),
  {
    surface: 'raised',
    title: 'Filters',
    priceStep: 100,
    currency: 'EUR',
    precision: 2,
    defaultOpen: () => ['price'],
    priceLabel: 'Price',
    priceToLabel: 'to',
    inStockLabel: 'In stock only',
    clearLabel: 'Clear all',
    resultsLabel: (count: number) => (count === 1 ? '1 result' : `${count} results`),
    showResultsLabel: (count: number) => (count === 1 ? 'Show 1 result' : `Show ${count} results`),
  },
)
const emit = defineEmits<{
  /** Fires when the shopper clicks the show-results button to apply the filters. */
  apply: []
  /** Fires after the shopper clears all filters, once price, options and in-stock are reset. */
  clear: []
}>()

/** The filter groups, one accordion item each, with their options' selection. */
const groups = defineModel<FiltersGroup[]>('groups', { default: () => [] })
/** The chosen price range as `[low, high]` in minor units; the full range when left out. */
const price = defineModel<number[]>('price')
/** Whether only products in stock are shown. */
const inStock = defineModel<boolean>('inStock', { default: false })

const range = computed(() => price.value ?? [props.priceMin, props.priceMax])
const selectedIn = (group: FiltersGroup) => group.options.filter((o) => o.selected).length

function toggle(groupValue: string, value: string) {
  groups.value = groups.value.map((group) =>
    group.value === groupValue ? ({ ...group, options: group.options.map((o) => (o.value === value ? { ...o, selected: !o.selected } : o)) } as FiltersGroup) : group,
  )
}

function clearAll() {
  price.value = [props.priceMin, props.priceMax]
  groups.value = groups.value.map((group) => ({ ...group, options: group.options.map((o) => ({ ...o, selected: false })) }) as FiltersGroup)
  inStock.value = false
  emit('clear')
}
</script>

<template>
  <Card :surface="surface" :class="props.class">
    <CardHeader>
      <CardTitle>{{ title }}</CardTitle>
      <CardDescription>
        <template v-if="description">{{ description }} · </template>{{ resultsLabel(results) }}
      </CardDescription>
      <CardAction>
        <Button variant="ghost" color="neutral" size="sm" @click="clearAll">{{ clearLabel }}</Button>
      </CardAction>
    </CardHeader>
    <CardContent>
      <Filter :reset="clearAll">
        <Accordion type="multiple" :default-value="defaultOpen">
          <AccordionItem value="price">
            <AccordionTrigger>{{ priceLabel }}</AccordionTrigger>
            <AccordionContent>
              <div class="flex flex-col gap-4 pt-1">
                <div class="flex items-center justify-between">
                  <Price :price="{ amount: range[0] ?? priceMin, currency, precision }" size="sm" />
                  <span data-slot="filters-range-to" class="fui-filters-range-to">{{ priceToLabel }}</span>
                  <Price :price="{ amount: range[1] ?? priceMax, currency, precision }" size="sm" />
                </div>
                <Slider :model-value="range" :min="priceMin" :max="priceMax" :step="priceStep" @update:model-value="price = $event" />
              </div>
            </AccordionContent>
          </AccordionItem>
          <AccordionItem v-for="group in groups" :key="group.value" :value="group.value">
            <AccordionTrigger>
              <span class="flex flex-1 items-center gap-2">
                {{ group.label }}
                <Badge v-if="selectedIn(group)" variant="primary" size="sm">{{ selectedIn(group) }}</Badge>
              </span>
            </AccordionTrigger>
            <AccordionContent>
              <FilterSwatch v-if="group.kind === 'swatch'" :options="group.options" @toggle="toggle(group.value, $event)" />
              <FilterList v-else :options="group.options" @toggle="toggle(group.value, $event)" />
            </AccordionContent>
          </AccordionItem>
        </Accordion>
        <FilterSwitch :value="inStock" :label="inStockLabel" @change="inStock = $event" />
      </Filter>
    </CardContent>
    <CardFooter>
      <Button class="w-full" @click="emit('apply')">{{ showResultsLabel(results) }}</Button>
    </CardFooter>
  </Card>
</template>
```

`components/sections/filters/types.ts`

```ts
import type { FilterOption, FilterSwatchOption } from '@library/components/ui/filter'

export interface FiltersListGroup {
  value: string
  label: string
  kind?: 'list'
  options: FilterOption[]
}

export interface FiltersSwatchGroup {
  value: string
  label: string
  kind: 'swatch'
  options: FilterSwatchOption[]
}

export type FiltersGroup = FiltersListGroup | FiltersSwatchGroup
```
