---
name: filter
title: Filter
url: https://ui.frontic.com/components/ui/filter.md
tier: ui
install: npx @frontic/ui add filter
files:
  - components/ui/filter/Filter.vue
  - components/ui/filter/FilterField.vue
  - components/ui/filter/FilterFieldContent.vue
  - components/ui/filter/FilterFieldDescription.vue
  - components/ui/filter/FilterFieldLabel.vue
  - components/ui/filter/FilterFieldTitle.vue
  - components/ui/filter/FilterGroup.vue
  - components/ui/filter/FilterList.vue
  - components/ui/filter/FilterPrice.vue
  - components/ui/filter/FilterRange.vue
  - components/ui/filter/FilterRating.vue
  - components/ui/filter/FilterReset.vue
  - components/ui/filter/FilterSlider.vue
  - components/ui/filter/FilterSwatch.vue
  - components/ui/filter/FilterSwitch.vue
  - components/ui/filter/FilterTrigger.vue
  - components/ui/filter/context.ts
  - components/ui/filter/index.ts
registryDependencies:
  - button
  - collapsible
  - format
  - input-group
  - popover
  - rating
  - slider
  - swatch
  - swatch-group
  - switch
dependencies:
  - "@lucide/vue"
  - "@vueuse/core"
  - class-variance-authority
  - reka-ui
---

# Filter

The parts of a storefront filter: option lists with counts and search, swatches, a price range, ratings and switches, in groups that open in place or as a popover and reset together.

## Install

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

Built from: [Button](https://ui.frontic.com/components/ui/button.md), [Collapsible](https://ui.frontic.com/components/ui/collapsible.md), [Format](https://ui.frontic.com/lib/format.md), [Input Group](https://ui.frontic.com/components/ui/input-group.md), [Popover](https://ui.frontic.com/components/ui/popover.md), [Rating](https://ui.frontic.com/components/ui/rating.md), [Slider](https://ui.frontic.com/components/ui/slider.md), [Swatch](https://ui.frontic.com/components/ui/swatch.md), [Swatch Group](https://ui.frontic.com/components/ui/swatch-group.md), [Switch](https://ui.frontic.com/components/ui/switch.md)

Used by: [Filters](https://ui.frontic.com/components/sections/filters.md), [Category Filter](https://ui.frontic.com/components/templates/category-filter.md), [Category Grid](https://ui.frontic.com/components/templates/category-grid.md), [Filter Sheet](https://ui.frontic.com/components/templates/filter-sheet.md), [Filter Sidebar](https://ui.frontic.com/components/templates/filter-sidebar.md), [Filter Toolbar](https://ui.frontic.com/components/templates/filter-toolbar.md)

npm packages: `@lucide/vue`, `@vueuse/core`, `class-variance-authority`, `reka-ui`

## Examples

```vue
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { TagGroup, TagGroupItem, TagGroupItemDelete, TagGroupItemText } from '@/components/ui/tag-group'
import { Filter, FilterGroup, FilterList, FilterPrice, FilterRange, FilterRating, FilterReset, FilterSwatch, FilterSwitch } from '@/components/ui/filter'

// The demo shop's men's jackets: catalog prices in euros, ratings, colours, and whether a reference price marks them on sale. Blizzard Defender is one product in three colours.
const JACKETS: { price: number; rating: number; colours: string[]; sale?: boolean }[] = [
  { price: 132, rating: 4.7, colours: ['Charcoal'], sale: true },
  { price: 94, rating: 4.4, colours: ['Slate blue'] },
  { price: 52, rating: 4.1, colours: ['Ochre'] },
  { price: 107, rating: 4.6, colours: ['Steel grey'] },
  { price: 84, rating: 4.3, colours: ['Dark olive'], sale: true },
  { price: 81, rating: 4.0, colours: ['Saffron'] },
  { price: 79, rating: 4.8, colours: ['Dusty pink'] },
  { price: 65, rating: 4.2, colours: ['Mustard'] },
  { price: 61, rating: 4.5, colours: ['Light grey'], sale: true },
  { price: 87, rating: 4.4, colours: ['Dark brown'] },
  { price: 126, rating: 4.9, colours: ['Aubergine', 'Dusty rose', 'Salmon'] },
  { price: 62, rating: 3.9, colours: ['Olive'] },
  { price: 68, rating: 4.3, colours: ['Terracotta'] },
  { price: 57, rating: 4.1, colours: ['Amber'] },
  { price: 91, rating: 4.6, colours: ['Charcoal'] },
  { price: 74, rating: 4.2, colours: ['Rust'] },
  { price: 149, rating: 4.8, colours: ['Sun yellow'] },
  { price: 126, rating: 4.7, colours: ['Stone grey'] },
  { price: 119.8, rating: 4.5, colours: ['Burnt orange'] },
]

const HEX: Record<string, string> = {
  Amber: '#e2a240',
  Aubergine: '#3d3347',
  'Burnt orange': '#c8704f',
  Charcoal: '#4a4a47',
  'Dark brown': '#4d3e34',
  'Dark olive': '#55573f',
  'Dusty pink': '#d6aaa6',
  'Dusty rose': '#b98a87',
  'Light grey': '#d0d0ce',
  Mustard: '#cf9e42',
  Ochre: '#c08a3c',
  Olive: '#a09d5c',
  Rust: '#b0593f',
  Saffron: '#e5a63c',
  Salmon: '#dc967b',
  'Slate blue': '#5f7680',
  'Steel grey': '#8e98a4',
  'Stone grey': '#9c9c9a',
  'Sun yellow': '#e5ad40',
  Terracotta: '#c4735f',
}

// 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)) / 10) * 10
const MAX = Math.ceil(Math.max(...JACKETS.map((j) => j.price)) / 10) * 10

const sizes = reactive(new Set<string>())
const colours = reactive(new Set<string>())
const price = ref<{ from?: number; to?: number }>({ from: MIN, to: MAX })
const onSale = ref(false)
const rating = ref(0)

const inRange = computed(() => JACKETS.filter((j) => j.price >= (price.value.from ?? MIN) && j.price <= (price.value.to ?? MAX) && (!onSale.value || j.sale) && j.rating >= rating.value))
const shown = computed(() => inRange.value.filter((j) => !colours.size || j.colours.some((c) => colours.has(c))))

const sizeOptions = computed(() => SIZES.map((size) => ({ option: size, value: size, count: shown.value.length, selected: sizes.has(size) })))
const colourOptions = computed(() =>
  Object.entries(HEX).map(([colour, hex]) => {
    const count = inRange.value.filter((j) => j.colours.includes(colour)).length
    return { value: colour, hex, label: colour, count, selected: colours.has(colour), disabled: !count && !colours.has(colour) }
  }),
)

// Every active filter as a chip; removing one clears that filter.
const active = computed(() => [
  ...(onSale.value ? [{ key: 'sale', label: 'On sale', clear: () => (onSale.value = false) }] : []),
  ...[...sizes].map((size) => ({ key: `size-${size}`, label: `Size ${size}`, clear: () => sizes.delete(size) })),
  ...[...colours].map((colour) => ({ key: `colour-${colour}`, label: colour, clear: () => colours.delete(colour) })),
  ...(price.value.from !== MIN || price.value.to !== MAX
    ? [{ key: 'price', label: `€${price.value.from ?? MIN} – €${price.value.to ?? MAX}`, clear: () => (price.value = { from: MIN, to: MAX }) }]
    : []),
  ...(rating.value ? [{ key: 'rating', label: `${rating.value} stars & up`, clear: () => (rating.value = 0) }] : []),
])

function remove(keys: unknown[]) {
  for (const chip of active.value) if (keys.includes(chip.key)) chip.clear()
}

function toggle(set: Set<string>, value: string) {
  if (set.has(value)) set.delete(value)
  else set.add(value)
}

function resetAll() {
  sizes.clear()
  colours.clear()
  price.value = { from: MIN, to: MAX }
  onSale.value = false
  rating.value = 0
}
</script>

<template>
  <Filter :reset="resetAll" class="flex w-72 flex-col gap-2">
    <TagGroup v-if="active.length" aria-label="Active filters" @remove="remove">
      <TagGroupItem v-for="chip in active" :key="chip.key" :value="chip.key">
        <TagGroupItemText>{{ chip.label }}</TagGroupItemText>
        <TagGroupItemDelete />
      </TagGroupItem>
    </TagGroup>
    <FilterSwitch label="On sale" description="Reduced from the original price" :value="onSale" @change="onSale = $event" />
    <FilterGroup type="collapsible" label="Size" :active-count="sizes.size || undefined">
      <FilterList :options="sizeOptions" @toggle="toggle(sizes, $event)" />
    </FilterGroup>
    <FilterGroup type="collapsible" default-open label="Colour" :active-count="colours.size || undefined">
      <FilterSwatch :options="colourOptions" @toggle="toggle(colours, $event)" />
    </FilterGroup>
    <FilterGroup type="collapsible" label="Price" :active-count="price.from !== MIN || price.to !== MAX ? 1 : undefined">
      <FilterPrice :min="MIN" :max="MAX" :step="10" :precision="0" :value="price" @change="price = $event" />
      <FilterRange unit="€" :min="MIN" :max="MAX" :step="10" :value="price" @change="price = { from: $event.from ?? MIN, to: $event.to ?? MAX }" />
    </FilterGroup>
    <FilterGroup type="collapsible" label="Rating" :active-count="rating ? 1 : undefined">
      <FilterRating :value="rating" @change="rating = $event" />
    </FilterGroup>
    <FilterReset />
  </Filter>
</template>
```

The example also uses `tag-group`, which `add filter` leaves out: `npx @frontic/ui add tag-group`.

## Anatomy

```vue
<Filter>
  <FilterSwitch>
    <FilterField>
      <FilterFieldContent>
        <FilterFieldLabel />
        <FilterFieldDescription />
      </FilterFieldContent>
    </FilterField>
  </FilterSwitch>
  <FilterGroup>
    <FilterList />
    <FilterSwatch />
    <FilterPrice>
      <FilterSlider>
        <FilterField>
          <FilterFieldTitle />
          <FilterFieldDescription />
        </FilterField>
      </FilterSlider>
    </FilterPrice>
    <FilterRange>
      <FilterField>
        <FilterFieldTitle />
        <FilterFieldDescription />
      </FilterField>
    </FilterRange>
    <FilterRating>
      <FilterField>
        <FilterFieldTitle />
      </FilterField>
    </FilterRating>
  </FilterGroup>
  <FilterReset />
</Filter>
```

## API reference

### Filter

Groups all parts of the filter and lets the user reset them together. Renders a `<div>`.

`data-slot="filter"` · `Filter.vue`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `asChild` | `boolean` | `false` | Change the default rendered element for the one passed as a child, merging their props and behavior.<br><br>Read our [Composition](https://www.reka-ui.com/docs/guides/composition) guide for more details. |
| `as` | `AsTag \| Component`: `ComponentOptions<any, any, any, ComputedOptions, MethodOptions, any, any, any, string, {}, {}, string, {}, {}, {}, string, ComponentProvideOptions> \| (props: any, ctx: Omit<{ attrs: Attrs; slots: Readonly<InternalSlots>; emit: (event: string, ...args: any[]) => void; expose: <Exposed extends Record<string, any> = Record<string, any>>(exposed?: Exposed \| undefined) => void; }, 'expose'>): any \| ComponentPublicInstanceConstructor<any, any, any, any, ComputedOptions, MethodOptions> \| 'a' \| 'button' \| 'div' \| 'form' \| 'h2' \| 'h3' \| 'img' \| 'input' \| 'label' \| 'li' \| 'nav' \| 'ol' \| 'p' \| 'span' \| 'svg' \| 'ul' \| 'template' \| {} & string` | `'div'` | The element or component this component should render as. Can be overwritten by `asChild`. |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |
| `reset` | `(() => void)`: `(): void` |  | Called when the shopper presses a `FilterReset` inside; clear every active filter here. |

### FilterField

Groups the title, control and description of one filter option. Renders a `<div>`.

`data-slot="filter-field"` · `FilterField.vue`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `asChild` | `boolean` | `false` | Change the default rendered element for the one passed as a child, merging their props and behavior.<br><br>Read our [Composition](https://www.reka-ui.com/docs/guides/composition) guide for more details. |
| `as` | `AsTag \| Component`: `ComponentOptions<any, any, any, ComputedOptions, MethodOptions, any, any, any, string, {}, {}, string, {}, {}, {}, string, ComponentProvideOptions> \| (props: any, ctx: Omit<{ attrs: Attrs; slots: Readonly<InternalSlots>; emit: (event: string, ...args: any[]) => void; expose: <Exposed extends Record<string, any> = Record<string, any>>(exposed?: Exposed \| undefined) => void; }, 'expose'>): any \| ComponentPublicInstanceConstructor<any, any, any, any, ComputedOptions, MethodOptions> \| 'a' \| 'button' \| 'div' \| 'form' \| 'h2' \| 'h3' \| 'img' \| 'input' \| 'label' \| 'li' \| 'nav' \| 'ol' \| 'p' \| 'span' \| 'svg' \| 'ul' \| 'template' \| {} & string` | `'div'` | The element or component this component should render as. Can be overwritten by `asChild`. |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |
| `orientation` | `'vertical' \| 'horizontal'` |  | Whether the control sits below or beside its title, exposed as `data-orientation` for styling. |
| `disabled` | `boolean` | `false` | Marks the option as unavailable with `data-disabled`, e.g. when no products match it. |

| Data attribute | Values | Description |
| --- | --- | --- |
| `data-disabled` |  | Present while the part is disabled. |
| `data-orientation` | `'vertical' \| 'horizontal'` | Carries the `orientation` prop. |

### FilterFieldContent

Stacks a filter option's label and description beside its control. Renders a `<div>`.

`data-slot="filter-field-content"` · `FilterFieldContent.vue`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `asChild` | `boolean` | `false` | Change the default rendered element for the one passed as a child, merging their props and behavior.<br><br>Read our [Composition](https://www.reka-ui.com/docs/guides/composition) guide for more details. |
| `as` | `AsTag \| Component`: `ComponentOptions<any, any, any, ComputedOptions, MethodOptions, any, any, any, string, {}, {}, string, {}, {}, {}, string, ComponentProvideOptions> \| (props: any, ctx: Omit<{ attrs: Attrs; slots: Readonly<InternalSlots>; emit: (event: string, ...args: any[]) => void; expose: <Exposed extends Record<string, any> = Record<string, any>>(exposed?: Exposed \| undefined) => void; }, 'expose'>): any \| ComponentPublicInstanceConstructor<any, any, any, any, ComputedOptions, MethodOptions> \| 'a' \| 'button' \| 'div' \| 'form' \| 'h2' \| 'h3' \| 'img' \| 'input' \| 'label' \| 'li' \| 'nav' \| 'ol' \| 'p' \| 'span' \| 'svg' \| 'ul' \| 'template' \| {} & string` | `'div'` | The element or component this component should render as. Can be overwritten by `asChild`. |

### FilterFieldDescription

Explains a filter option below its label. Renders a `<p>`.

`data-slot="filter-field-description"` · `FilterFieldDescription.vue`

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

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `asChild` | `boolean` | `false` | Change the default rendered element for the one passed as a child, merging their props and behavior.<br><br>Read our [Composition](https://www.reka-ui.com/docs/guides/composition) guide for more details. |
| `as` | `AsTag \| Component`: `ComponentOptions<any, any, any, ComputedOptions, MethodOptions, any, any, any, string, {}, {}, string, {}, {}, {}, string, ComponentProvideOptions> \| (props: any, ctx: Omit<{ attrs: Attrs; slots: Readonly<InternalSlots>; emit: (event: string, ...args: any[]) => void; expose: <Exposed extends Record<string, any> = Record<string, any>>(exposed?: Exposed \| undefined) => void; }, 'expose'>): any \| ComponentPublicInstanceConstructor<any, any, any, any, ComputedOptions, MethodOptions> \| 'a' \| 'button' \| 'div' \| 'form' \| 'h2' \| 'h3' \| 'img' \| 'input' \| 'label' \| 'li' \| 'nav' \| 'ol' \| 'p' \| 'span' \| 'svg' \| 'ul' \| 'template' \| {} & string` | `'p'` | The element or component this component should render as. Can be overwritten by `asChild`. |

### FilterFieldLabel

Labels the control of a filter option. Renders a `<label>`.

`data-slot="filter-field-label"` · `FilterFieldLabel.vue`

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

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `asChild` | `boolean` | `false` | Change the default rendered element for the one passed as a child, merging their props and behavior.<br><br>Read our [Composition](https://www.reka-ui.com/docs/guides/composition) guide for more details. |
| `as` | `AsTag \| Component`: `ComponentOptions<any, any, any, ComputedOptions, MethodOptions, any, any, any, string, {}, {}, string, {}, {}, {}, string, ComponentProvideOptions> \| (props: any, ctx: Omit<{ attrs: Attrs; slots: Readonly<InternalSlots>; emit: (event: string, ...args: any[]) => void; expose: <Exposed extends Record<string, any> = Record<string, any>>(exposed?: Exposed \| undefined) => void; }, 'expose'>): any \| ComponentPublicInstanceConstructor<any, any, any, any, ComputedOptions, MethodOptions> \| 'a' \| 'button' \| 'div' \| 'form' \| 'h2' \| 'h3' \| 'img' \| 'input' \| 'label' \| 'li' \| 'nav' \| 'ol' \| 'p' \| 'span' \| 'svg' \| 'ul' \| 'template' \| {} & string` | `'label'` | The element or component this component should render as. Can be overwritten by `asChild`. |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |
| `for` | `string` |  | The `id` of the control the label belongs to, so clicking the label toggles it. |

### FilterFieldTitle

Names a group of filter options. Renders a `<div>`.

`data-slot="filter-field-title"` · `FilterFieldTitle.vue`

Styling: `filter-field-title`, which extends [`label`](https://ui.frontic.com/DESIGN.md#label). Change it there and every part that extends it follows.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `asChild` | `boolean` | `false` | Change the default rendered element for the one passed as a child, merging their props and behavior.<br><br>Read our [Composition](https://www.reka-ui.com/docs/guides/composition) guide for more details. |
| `as` | `AsTag \| Component`: `ComponentOptions<any, any, any, ComputedOptions, MethodOptions, any, any, any, string, {}, {}, string, {}, {}, {}, string, ComponentProvideOptions> \| (props: any, ctx: Omit<{ attrs: Attrs; slots: Readonly<InternalSlots>; emit: (event: string, ...args: any[]) => void; expose: <Exposed extends Record<string, any> = Record<string, any>>(exposed?: Exposed \| undefined) => void; }, 'expose'>): any \| ComponentPublicInstanceConstructor<any, any, any, any, ComputedOptions, MethodOptions> \| 'a' \| 'button' \| 'div' \| 'form' \| 'h2' \| 'h3' \| 'img' \| 'input' \| 'label' \| 'li' \| 'nav' \| 'ol' \| 'p' \| 'span' \| 'svg' \| 'ul' \| 'template' \| {} & string` | `'div'` | The element or component this component should render as. Can be overwritten by `asChild`. |

### FilterGroup

Shows a labelled set of filter options that opens and closes.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |
| `label` (required) | `string` |  | Shown on the trigger that opens the group, e.g. `Brand` or `Size`. |
| `activeCount` | `number` |  | How many options in the group are active; shown on the trigger when above zero. |
| `type` | `'collapsible' \| 'popover'` | `'popover'` | Opens the options in a floating popover, or expands them inline below the trigger as in a sidebar. |

| Data attribute | Values | Description |
| --- | --- | --- |
| `data-size` |  | The size the part is drawn at. |

### FilterList

Lists filter options with result counts that the user can toggle and search.

`data-slot="filter-list"` · `FilterList.vue`

Styling: `filter-list-check`, which extends [`choice`](https://ui.frontic.com/DESIGN.md#choice) and [`choice-box`](https://ui.frontic.com/DESIGN.md#choice-box); `filter-list-item`, which extends [`menu-item`](https://ui.frontic.com/DESIGN.md#menu-item); `filter-list-search`, which extends [`control`](https://ui.frontic.com/DESIGN.md#control) and [`control-line`](https://ui.frontic.com/DESIGN.md#control-line). Change them there and every part that extends them follows.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |
| `options` (required) | `FilterOption[]` |  | The options to list, each with its label, value, result count and selected state. |
| `searchable` | `boolean` | `false` | Shows a search field above the list that narrows the options by label. |
| `disabled` | `boolean` | `false` | Disables every option in the list. |
| `searchPlaceholder` | `string` | `'Search...'` | Shown in the empty search field. |

| Event | Payload | Description |
| --- | --- | --- |
| `toggle` | `value: string` | Fires with an option's value each time the user selects or deselects it. |

| Data attribute | Values | Description |
| --- | --- | --- |
| `data-disabled` |  | Present while the part is disabled. |
| `data-searchable` |  | Present when the list has a search field. |

### FilterTrigger

Opens and closes a filter group, showing its label and count of active options.

`data-slot="filter-trigger"` · `FilterTrigger.vue`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |
| `label` (required) | `string` |  | Shown on the trigger, e.g. `Brand` or `Size`. |
| `activeCount` | `number` |  | How many options in the group are active; shown as a count when above zero. |

| Data attribute | Values | Description |
| --- | --- | --- |
| `data-size` |  | The size the part is drawn at. |

### FilterPrice

Lets the user limit products to a price range with a slider.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |
| `label` | `string` |  | Shown as the filter's title, e.g. `Price`. |
| `description` | `string` |  | Shown as a hint below the title. |
| `ariaLabel` | `string` |  | Names the slider for screen readers when its title sits outside it, e.g. on an accordion trigger or a popover button. |
| `currency` | `string` | `'EUR'` | ISO 4217 code the amounts are shown in, e.g. `EUR`. |
| `precision` | `number` | `2` | Decimal places of the currency; amounts are in its minor unit, so `2` means cents. |
| `locale` | `string` |  | BCP 47 locale for formatting the amounts; the ConfigProvider's when left out. |
| `min` | `number` | `0` | Lowest price on the slider, in the currency's minor unit, e.g. cents. |
| `max` | `number` | `100000` | Highest price on the slider, in the currency's minor unit, e.g. cents. |
| `step` | `number` | `100` | Amount the slider moves per step, in the currency's minor unit, e.g. cents. |
| `value` | `{ from?: number \| undefined; to?: number \| undefined; }` |  | The selected price range in minor units; with only `to`, a single handle sets the highest price. |
| `disabled` | `boolean` | `false` | Greys out the slider so the shopper cannot change it. |
| `upTo` | `((value: string) => string)`: `(value: string): string` | ``Up to ${value}`` | Builds the text shown for a single-handle maximum from the formatted price. |

| Event | Payload | Description |
| --- | --- | --- |
| `change` | `value: { from?: number \| undefined; to?: number \| undefined; }` | Fires with the selected range in minor units as the user moves the slider; with a single handle, only `to`. |

| Data attribute | Values | Description |
| --- | --- | --- |
| `data-disabled` |  | Present while the part is disabled. |
| `data-orientation` | `'horizontal' \| 'vertical'` | The direction the part is laid out in. |

### FilterRange

Lets the user limit a value by typing a lowest and highest number.

`data-slot="filter-range"` · `FilterRange.vue`

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

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |
| `label` | `string` |  | Shown as the filter's title, e.g. `Width`. |
| `description` | `string` |  | Shown as a hint below the title. |
| `unit` | `string` |  | Shown inside both inputs, e.g. `cm` or `€`. |
| `unitPosition` | `'start' \| 'end'` | `'start'` | Whether the unit sits before or after the number in each input. |
| `min` | `number` |  | Lowest number the inputs accept. |
| `max` | `number` |  | Highest number the inputs accept. |
| `step` | `number` |  | Amount the input arrows change the number by. |
| `value` | `{ from?: number \| undefined; to?: number \| undefined; }` |  | The current lowest and highest values; either may be left open. |
| `disabled` | `boolean` | `false` | Greys out both inputs so the shopper cannot change them. |
| `placeholderFrom` | `string` | `'From'` | Shown in the empty lowest-value input. |
| `placeholderTo` | `string` | `'To'` | Shown in the empty highest-value input. |
| `separator` | `string` | `'–'` | Shown between the two inputs. |

| Event | Payload | Description |
| --- | --- | --- |
| `change` | `value: { from?: number \| undefined; to?: number \| undefined; }` | Fires with the lowest and highest numbers when the user changes either input, leaving out an empty one. |

| Data attribute | Values | Description |
| --- | --- | --- |
| `data-disabled` |  | Present while the part is disabled. |
| `data-orientation` | `'horizontal' \| 'vertical'` | The direction the part is laid out in. |

### FilterRating

Lets the user limit products to those with a minimum star rating.

`data-slot="filter-rating"` · `FilterRating.vue`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |
| `value` | `number` | `0` | The selected minimum number of stars; `0` for no rating filter. |
| `label` | `string` |  | Shown as the filter's title, e.g. `Rating`. |
| `max` | `number` | `5` | How many stars to show. |
| `disabled` | `boolean` | `false` | Greys out the stars so the shopper cannot pick one. |
| `starLabel` | `((stars: number) => string)`: `(stars: number): string` | ``${stars} ${stars === 1 ? 'star' : 'stars'} and up`` | Builds each star's screen-reader label from its star count. |
| `suffix` | `string` | `'& up'` | Shown after the stars once a rating is selected. |

| Event | Payload | Description |
| --- | --- | --- |
| `change` | `value: number` | Fires with the minimum star count the user picks, or `0` when they clear it. |

| Data attribute | Values | Description |
| --- | --- | --- |
| `data-disabled` |  | Present while the part is disabled. |
| `data-orientation` | `'horizontal' \| 'vertical'` | The direction the part is laid out in. |

### FilterReset

Lets the user clear all filters at once. Renders a `<div>`.

`data-slot="filter-reset"` · `FilterReset.vue`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |
| `label` | `string` | `'Reset'` | Shown on the reset button; the default slot replaces it. |

| Event | Payload | Description |
| --- | --- | --- |
| `reset` |  | Fires when the user clicks the reset button, after the surrounding filter's own reset runs. |

### FilterSlider

Lets the user limit a numeric value to a range with a slider.

`data-slot="filter-slider"` · `FilterSlider.vue`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |
| `label` | `string` |  | Shown as the filter's title. |
| `description` | `string` |  | Shown as a hint below the title. |
| `ariaLabel` | `string` |  | Names the slider for screen readers when its title sits outside it, e.g. on an accordion trigger or a popover button. |
| `min` | `number` | `0` | Lowest value on the slider. |
| `max` | `number` | `1000` | Highest value on the slider. |
| `step` | `number` | `1` | Amount the slider moves per step. |
| `value` | `{ from?: number \| undefined; to?: number \| undefined; }` |  | The selected range; with only `to`, a single handle sets the highest value. |
| `disabled` | `boolean` | `false` | Greys out the slider so the shopper cannot change it. |
| `formatValue` | `((value: number) => string)`: `(value: number): string` |  | Formats each value in the readout next to the title, e.g. adding a unit; the plain number when left out. |
| `upTo` | `((value: string) => string)`: `(value: string): string` | ``Up to ${value}`` | Builds the text shown for a single-handle maximum from the formatted value. |

| Event | Payload | Description |
| --- | --- | --- |
| `change` | `value: { from?: number \| undefined; to?: number \| undefined; }` | Fires with the selected range as the user moves the slider; with a single handle, only `to`. |

| Data attribute | Values | Description |
| --- | --- | --- |
| `data-disabled` |  | Present while the part is disabled. |
| `data-orientation` | `'horizontal' \| 'vertical'` | The direction the part is laid out in. |

### FilterSwatch

Lists colour options as swatches that the user can toggle.

`data-slot="filter-swatch"` · `FilterSwatch.vue`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |
| `options` (required) | `FilterSwatchOption[]` |  | The colours to list, each with its hex, label, result count and selected state. |
| `type` | `'single' \| 'multiple'` | `'multiple'` | Whether the shopper can select one colour or several. |
| `disabled` | `boolean` | `false` | Disables every swatch. |
| `shape` | `SwatchShape`: `'default' \| 'pill'` | `'pill'` | The swatch corners: rounded or fully round. |
| `size` | `SwatchSize`: `'md' \| 'xs' \| 'sm' \| 'lg'` | `'xs'` | The swatch diameter. |
| `orientation` | `SwatchGroupOrientation`: `'vertical' \| 'horizontal'` | `'vertical'` | A scrolling column of swatches with their labels, or a row of swatches. |
| `labelClass` | `ClassValue`: `null \| string \| false \| Record<string, any> \| ClassValue[]` |  | Classes for each swatch's label; unused for options that carry a count. |

| Event | Payload | Description |
| --- | --- | --- |
| `toggle` | `value: string` | Fires with a colour's value each time the user selects or deselects it. |

### FilterSwitch

Lets the user turn a yes-or-no filter on or off.

`data-slot="filter-switch"` · `FilterSwitch.vue`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |
| `value` | `boolean` | `false` | Whether the filter is on. |
| `label` (required) | `string` |  | Shown next to the switch, e.g. `In stock only`. |
| `description` | `string` |  | Shown as a hint below the label. |
| `disabled` | `boolean` | `false` | Greys out the switch so the shopper cannot change it. |

| Event | Payload | Description |
| --- | --- | --- |
| `change` | `value: boolean` | Fires with the new on or off state when the user flips the switch. |

| Data attribute | Values | Description |
| --- | --- | --- |
| `data-disabled` |  | Present while the part is disabled. |

## Exports

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

- `FilterOption`: below
- `FilterSwatchOption`: below

```ts
interface FilterOption {
  option: string
  value: string
  count: number
  selected?: boolean
  disabled?: boolean
}

interface FilterSwatchOption {
  value: string
  hex: string
  label?: string
  count?: number
  selected?: boolean
  disabled?: boolean
}
```

## Styling

| Part | Recipe |
| --- | --- |
| `swatch` | [`filter-swatch`](https://ui.frontic.com/DESIGN.md#filter-swatch) |
| `switch` | [`filter-switch`](https://ui.frontic.com/DESIGN.md#filter-switch) |
| `slider` | [`filter-slider`](https://ui.frontic.com/DESIGN.md#filter-slider) |
| `reset-root` | [`filter-reset-root`](https://ui.frontic.com/DESIGN.md#filter-reset-root) |
| `rating` | [`filter-rating`](https://ui.frontic.com/DESIGN.md#filter-rating) |
| `range` | [`filter-range`](https://ui.frontic.com/DESIGN.md#filter-range) |
| `list-root` | [`filter-list-root`](https://ui.frontic.com/DESIGN.md#filter-list-root) |
| `field` | [`filter-field`](https://ui.frontic.com/DESIGN.md#filter-field) |
| `field-content` | [`filter-field-content`](https://ui.frontic.com/DESIGN.md#filter-field-content) |
| `trigger` | [`filter-trigger`](https://ui.frontic.com/DESIGN.md#filter-trigger) |
| `trigger-count` | [`filter-trigger-count`](https://ui.frontic.com/DESIGN.md#filter-trigger-count) |
| `reset` | [`filter-reset`](https://ui.frontic.com/DESIGN.md#filter-reset) |
| `group-content` | [`filter-group-content`](https://ui.frontic.com/DESIGN.md#filter-group-content) |
| `list` | [`filter-list`](https://ui.frontic.com/DESIGN.md#filter-list) |
| `list-search` | [`filter-list-search`](https://ui.frontic.com/DESIGN.md#filter-list-search) |
| `list-item` | [`filter-list-item`](https://ui.frontic.com/DESIGN.md#filter-list-item) |
| `list-check` | [`filter-list-check`](https://ui.frontic.com/DESIGN.md#filter-list-check) |
| `meta` | [`filter-meta`](https://ui.frontic.com/DESIGN.md#filter-meta) |
| `swatch-text` | [`filter-swatch-text`](https://ui.frontic.com/DESIGN.md#filter-swatch-text) |
| `rating-star` | [`filter-rating-star`](https://ui.frontic.com/DESIGN.md#filter-rating-star) |
| `field-label` | [`filter-field-label`](https://ui.frontic.com/DESIGN.md#filter-field-label) |
| `field-title` | [`filter-field-title`](https://ui.frontic.com/DESIGN.md#filter-field-title) |
| `field-description` | [`filter-field-description`](https://ui.frontic.com/DESIGN.md#filter-field-description) |
| `range-separator` | [`filter-range-separator`](https://ui.frontic.com/DESIGN.md#filter-range-separator) |
| `focus-ring` | [`focus-ring`](https://ui.frontic.com/DESIGN.md#focus-ring) |
| `disabled` | [`disabled`](https://ui.frontic.com/DESIGN.md#disabled) |

States with a look of their own: focus, checked, disabled and invalid.

## Source

`components/ui/filter/Filter.vue`

```vue
<script lang="ts">
/** Groups all parts of the filter and lets the user reset them together. */
export default {}
</script>

<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import { Primitive } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { provide } from 'vue'

const props = withDefaults(
  defineProps<
    PrimitiveProps & {
      class?: HTMLAttributes['class']
      /** Called when the shopper presses a `FilterReset` inside; clear every active filter here. */
      reset?: () => void
    }
  >(),
  { as: 'div' },
)

provide('filter-reset', () => props.reset?.())
</script>

<template>
  <Primitive :as="props.as" :as-child="props.asChild" data-slot="filter" :class="props.class">
    <slot />
  </Primitive>
</template>
```

`components/ui/filter/FilterField.vue`

```vue
<script lang="ts">
/** Groups the title, control and description of one filter option. */
export default {}
</script>

<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import { Primitive } from 'reka-ui'
import type { HTMLAttributes } from 'vue'

const props = withDefaults(
  defineProps<
    PrimitiveProps & {
      class?: HTMLAttributes['class']
      /** Whether the control sits below or beside its title, exposed as `data-orientation` for styling. */
      orientation?: 'vertical' | 'horizontal'
      /** Marks the option as unavailable with `data-disabled`, e.g. when no products match it. */
      disabled?: boolean
    }
  >(),
  { as: 'div' },
)
</script>

<template>
  <Primitive
    :as="props.as"
    :as-child="props.asChild"
    role="group"
    data-slot="filter-field"
    :data-orientation="orientation"
    :data-disabled="disabled || undefined"
    :class="['fui-filter-field', props.class]"
  >
    <slot />
  </Primitive>
</template>
```

`components/ui/filter/FilterFieldContent.vue`

```vue
<script lang="ts">
/** Stacks a filter option's label and description beside its control. */
export default {}
</script>

<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import { Primitive } from 'reka-ui'
const props = withDefaults(defineProps<PrimitiveProps>(), { as: 'div' })
</script>

<template>
  <Primitive :as="props.as" :as-child="props.asChild" data-slot="filter-field-content" class="fui-filter-field-content">
    <slot />
  </Primitive>
</template>
```

`components/ui/filter/FilterFieldDescription.vue`

```vue
<script lang="ts">
/** Explains a filter option below its label. */
export default {}
</script>

<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import { Primitive } from 'reka-ui'
const props = withDefaults(defineProps<PrimitiveProps>(), { as: 'p' })
</script>

<template>
  <Primitive :as="props.as" :as-child="props.asChild" data-slot="filter-field-description" class="fui-filter-field-description">
    <slot />
  </Primitive>
</template>
```

`components/ui/filter/FilterFieldLabel.vue`

```vue
<script lang="ts">
/** Labels the control of a filter option. */
export default {}
</script>

<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import { Primitive } from 'reka-ui'
import type { HTMLAttributes } from 'vue'

const props = withDefaults(
  defineProps<
    PrimitiveProps & {
      class?: HTMLAttributes['class']
      /** The `id` of the control the label belongs to, so clicking the label toggles it. */
      for?: string
    }
  >(),
  { as: 'label' },
)
</script>

<template>
  <Primitive :as="props.as" :as-child="props.asChild" data-slot="filter-field-label" :for="props.for" :class="['fui-filter-field-label cursor-pointer', props.class]">
    <slot />
  </Primitive>
</template>
```

`components/ui/filter/FilterFieldTitle.vue`

```vue
<script lang="ts">
/** Names a group of filter options. */
export default {}
</script>

<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import { Primitive } from 'reka-ui'
const props = withDefaults(defineProps<PrimitiveProps>(), { as: 'div' })
</script>

<template>
  <Primitive :as="props.as" :as-child="props.asChild" data-slot="filter-field-title" class="fui-filter-field-title">
    <slot />
  </Primitive>
</template>
```

`components/ui/filter/FilterGroup.vue`

```vue
<script lang="ts">
/** Shows a labelled set of filter options that opens and closes. */
export default {}
</script>

<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { computed, provide } from 'vue'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@library/components/ui/collapsible'
import { Popover, PopoverContent, PopoverTrigger } from '@library/components/ui/popover'
import { FILTER_GROUP_LABEL } from './context'
import FilterTrigger from './FilterTrigger.vue'

const props = withDefaults(
  defineProps<{
    class?: HTMLAttributes['class']
    /** Shown on the trigger that opens the group, e.g. `Brand` or `Size`. */
    label: string
    /** How many options in the group are active; shown on the trigger when above zero. */
    activeCount?: number
    /** Opens the options in a floating popover, or expands them inline below the trigger as in a sidebar. */
    type?: 'popover' | 'collapsible'
  }>(),
  {
    type: 'popover',
  },
)

provide(
  FILTER_GROUP_LABEL,
  computed(() => props.label),
)
</script>

<template>
  <Popover v-if="type === 'popover'">
    <PopoverTrigger as-child>
      <FilterTrigger :label="label" :active-count="activeCount" :class="props.class" />
    </PopoverTrigger>
    <PopoverContent align="start" :side-offset="4" class="fui-filter-group-content">
      <slot />
    </PopoverContent>
  </Popover>

  <Collapsible v-else>
    <CollapsibleTrigger as-child>
      <FilterTrigger :label="label" :active-count="activeCount" :class="props.class" />
    </CollapsibleTrigger>
    <CollapsibleContent class="pt-3">
      <slot />
    </CollapsibleContent>
  </Collapsible>
</template>
```

`components/ui/filter/FilterList.vue`

```vue
<script lang="ts">
/** Lists filter options with result counts that the user can toggle and search. */
export default {}
</script>

<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { computed, ref } from 'vue'
import type { AcceptableValue } from 'reka-ui'
import { ListboxContent, ListboxFilter, ListboxItem, ListboxItemIndicator, ListboxRoot, ListboxVirtualizer, useFilter } from 'reka-ui'

export interface FilterOption {
  option: string
  value: string
  count: number
  selected?: boolean
  disabled?: boolean
}

const props = withDefaults(
  defineProps<{
    class?: HTMLAttributes['class']
    /** The options to list, each with its label, value, result count and selected state. */
    options: FilterOption[]
    /** Shows a search field above the list that narrows the options by label. */
    searchable?: boolean
    /** Disables every option in the list. */
    disabled?: boolean
    /** Shown in the empty search field. */
    searchPlaceholder?: string
  }>(),
  {
    searchable: false,
    disabled: false,
    searchPlaceholder: 'Search...',
  },
)

const emit = defineEmits<{
  /** Fires with an option's value each time the user selects or deselects it. */
  toggle: [value: string]
}>()

const searchQuery = ref('')

const { contains } = useFilter({ sensitivity: 'base' })
const filteredOptions = computed(() => (searchQuery.value ? props.options.filter((o) => contains(o.option, searchQuery.value)) : props.options))

const selectedValues = computed(() => props.options.filter((o) => o.selected).map((o) => o.value))

function handleChange(val: AcceptableValue) {
  const newValues = (Array.isArray(val) ? val : [val]).filter((v): v is string => typeof v === 'string')
  const oldSet = new Set(selectedValues.value)
  const newSet = new Set(newValues)
  for (const v of newSet) {
    if (!oldSet.has(v)) emit('toggle', v)
  }
  for (const v of oldSet) {
    if (!newSet.has(v)) emit('toggle', v)
  }
}
</script>

<template>
  <ListboxRoot
    multiple
    selection-behavior="toggle"
    data-slot="filter-list"
    :model-value="selectedValues"
    :data-searchable="searchable || undefined"
    :class="['fui-filter-list-root', props.class]"
    @update:model-value="handleChange"
  >
    <ListboxFilter
      v-if="searchable"
      v-model="searchQuery"
      data-slot="filter-list-search"
      class="fui-filter-list-search w-full outline-none focus-visible:fui-focus-ring"
      :placeholder="searchPlaceholder"
    />
    <ListboxContent data-slot="filter-list-content" :class="['fui-filter-list scroll-py-1 overflow-y-auto outline-none focus-visible:fui-focus-ring', !searchable && 'h-auto max-h-70']">
      <ListboxVirtualizer :options="filteredOptions" :estimate-size="36" :text-content="(opt: FilterOption) => opt.option">
        <template #default="{ option: opt }">
          <ListboxItem
            :value="opt.value"
            :disabled="disabled || opt.disabled"
            data-slot="filter-list-item"
            class="fui-filter-list-item flex w-full cursor-default items-center outline-none select-none data-disabled:pointer-events-none data-disabled:fui-disabled"
          >
            <span
              data-slot="filter-list-check"
              :data-state="selectedValues.includes(opt.value) ? 'checked' : 'unchecked'"
              class="fui-filter-list-check flex shrink-0 items-center justify-center transition-colors"
            >
              <ListboxItemIndicator>
                <svg data-icon xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
                  <path d="M20 6 9 17l-5-5" />
                </svg>
              </ListboxItemIndicator>
            </span>
            <span class="flex min-w-0 flex-1 items-center justify-between gap-2">
              <span>{{ opt.option }}</span>
              <span data-slot="filter-list-count" class="fui-filter-meta tabular-nums">{{ opt.count }}</span>
            </span>
          </ListboxItem>
        </template>
      </ListboxVirtualizer>
    </ListboxContent>
  </ListboxRoot>
</template>
```

`components/ui/filter/FilterPrice.vue`

```vue
<script lang="ts">
/** Lets the user limit products to a price range with a slider. */
export default {}
</script>

<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { type PriceType, formatPrice } from '@library/lib/format'
import { reactiveOmit } from '@vueuse/core'
import FilterSlider from './FilterSlider.vue'
import { useLocale } from 'reka-ui'
import { toRef } from 'vue'

const props = withDefaults(
  defineProps<{
    class?: HTMLAttributes['class']
    /** Shown as the filter's title, e.g. `Price`. */
    label?: string
    /** Shown as a hint below the title. */
    description?: string
    /** Names the slider for screen readers when its title sits outside it, e.g. on an accordion trigger or a popover button. */
    ariaLabel?: string
    /** ISO 4217 code the amounts are shown in, e.g. `EUR`. */
    currency?: string
    /** Decimal places of the currency; amounts are in its minor unit, so `2` means cents. */
    precision?: number
    /** BCP 47 locale for formatting the amounts; the ConfigProvider's when left out. */
    locale?: string
    /** Lowest price on the slider, in the currency's minor unit, e.g. cents. */
    min?: number
    /** Highest price on the slider, in the currency's minor unit, e.g. cents. */
    max?: number
    /** Amount the slider moves per step, in the currency's minor unit, e.g. cents. */
    step?: number
    /** The selected price range in minor units; with only `to`, a single handle sets the highest price. */
    value?: { from?: number; to?: number }
    /** Greys out the slider so the shopper cannot change it. */
    disabled?: boolean
    /** Builds the text shown for a single-handle maximum from the formatted price. */
    upTo?: (value: string) => string
  }>(),
  {
    upTo: (value: string) => `Up to ${value}`,
    currency: 'EUR',
    precision: 2,
    min: 0,
    max: 100000,
    step: 100,
    disabled: false,
  },
)
const locale = useLocale(toRef(() => props.locale))

const emit = defineEmits<{
  /** Fires with the selected range in minor units as the user moves the slider; with a single handle, only `to`. */
  change: [value: { from?: number; to?: number }]
}>()

const slider = reactiveOmit(props, 'currency', 'precision', 'locale')

function fmt(amount: number): string {
  const price: PriceType = {
    amount,
    currency: props.currency,
    precision: props.precision,
  }
  return formatPrice(price, locale.value)
}
</script>

<template>
  <FilterSlider v-bind="slider" :format-value="fmt" @change="emit('change', $event)" />
</template>
```

`components/ui/filter/FilterRange.vue`

```vue
<script lang="ts">
/** Lets the user limit a value by typing a lowest and highest number. */
export default {}
</script>

<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { ref, watch } from 'vue'
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from '@library/components/ui/input-group'
import FilterField from './FilterField.vue'
import FilterFieldDescription from './FilterFieldDescription.vue'
import FilterFieldTitle from './FilterFieldTitle.vue'

const props = withDefaults(
  defineProps<{
    class?: HTMLAttributes['class']
    /** Shown as the filter's title, e.g. `Width`. */
    label?: string
    /** Shown as a hint below the title. */
    description?: string
    /** Shown inside both inputs, e.g. `cm` or `€`. */
    unit?: string
    /** Whether the unit sits before or after the number in each input. */
    unitPosition?: 'start' | 'end'
    /** Lowest number the inputs accept. */
    min?: number
    /** Highest number the inputs accept. */
    max?: number
    /** Amount the input arrows change the number by. */
    step?: number
    /** The current lowest and highest values; either may be left open. */
    value?: { from?: number; to?: number }
    /** Greys out both inputs so the shopper cannot change them. */
    disabled?: boolean
    /** Shown in the empty lowest-value input. */
    placeholderFrom?: string
    /** Shown in the empty highest-value input. */
    placeholderTo?: string
    /** Shown between the two inputs. */
    separator?: string
  }>(),
  {
    unitPosition: 'start',
    disabled: false,
    placeholderFrom: 'From',
    placeholderTo: 'To',
    separator: '–',
  },
)

const emit = defineEmits<{
  /** Fires with the lowest and highest numbers when the user changes either input, leaving out an empty one. */
  change: [value: { from?: number; to?: number }]
}>()

const fromValue = ref(props.value?.from !== undefined ? String(props.value.from) : '')
const toValue = ref(props.value?.to !== undefined ? String(props.value.to) : '')

watch(
  () => props.value,
  () => {
    fromValue.value = props.value?.from !== undefined ? String(props.value.from) : ''
    toValue.value = props.value?.to !== undefined ? String(props.value.to) : ''
  },
  { deep: true },
)

function handleFromChange(event: Event) {
  const input = event.target as HTMLInputElement
  const val = input.value.trim()
  fromValue.value = val
  emitChange()
}

function handleToChange(event: Event) {
  const input = event.target as HTMLInputElement
  const val = input.value.trim()
  toValue.value = val
  emitChange()
}

function emitChange() {
  const from = fromValue.value !== '' ? Number(fromValue.value) : undefined
  const to = toValue.value !== '' ? Number(toValue.value) : undefined

  if (from !== undefined && Number.isNaN(from)) return
  if (to !== undefined && Number.isNaN(to)) return

  emit('change', {
    ...(from !== undefined ? { from } : {}),
    ...(to !== undefined ? { to } : {}),
  })
}
</script>

<template>
  <FilterField data-slot="filter-range" :disabled="disabled" :class="['fui-filter-range', props.class]">
    <FilterFieldTitle v-if="label">
      {{ label }}
    </FilterFieldTitle>
    <FilterFieldDescription v-if="description">
      {{ description }}
    </FilterFieldDescription>
    <div class="flex items-center gap-2">
      <InputGroup class="flex-1">
        <InputGroupAddon v-if="unit && unitPosition === 'start'" align="inline-start">
          <InputGroupText>{{ unit }}</InputGroupText>
        </InputGroupAddon>
        <InputGroupInput type="number" :min="min" :max="max" :step="step" :disabled="disabled" :placeholder="placeholderFrom" :model-value="fromValue" @change="handleFromChange" />
        <InputGroupAddon v-if="unit && unitPosition === 'end'" align="inline-end">
          <InputGroupText>{{ unit }}</InputGroupText>
        </InputGroupAddon>
      </InputGroup>
      <span class="fui-filter-range-separator shrink-0">{{ separator }}</span>
      <InputGroup class="flex-1">
        <InputGroupAddon v-if="unit && unitPosition === 'start'" align="inline-start">
          <InputGroupText>{{ unit }}</InputGroupText>
        </InputGroupAddon>
        <InputGroupInput type="number" :min="min" :max="max" :step="step" :disabled="disabled" :placeholder="placeholderTo" :model-value="toValue" @change="handleToChange" />
        <InputGroupAddon v-if="unit && unitPosition === 'end'" align="inline-end">
          <InputGroupText>{{ unit }}</InputGroupText>
        </InputGroupAddon>
      </InputGroup>
    </div>
  </FilterField>
</template>
```

`components/ui/filter/FilterRating.vue`

```vue
<script lang="ts">
/** Lets the user limit products to those with a minimum star rating. */
export default {}
</script>

<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { computed } from 'vue'
import type { AcceptableValue } from 'reka-ui'
import { ToggleGroupItem, ToggleGroupRoot } from 'reka-ui'
import { RatingIcon } from '@library/components/ui/rating'
import FilterField from './FilterField.vue'
import FilterFieldTitle from './FilterFieldTitle.vue'

const props = withDefaults(
  defineProps<{
    class?: HTMLAttributes['class']
    /** The selected minimum number of stars; `0` for no rating filter. */
    value?: number
    /** Shown as the filter's title, e.g. `Rating`. */
    label?: string
    /** How many stars to show. */
    max?: number
    /** Greys out the stars so the shopper cannot pick one. */
    disabled?: boolean
    /** Builds each star's screen-reader label from its star count. */
    starLabel?: (stars: number) => string
    /** Shown after the stars once a rating is selected. */
    suffix?: string
  }>(),
  {
    value: 0,
    max: 5,
    disabled: false,
    starLabel: (stars: number) => `${stars} ${stars === 1 ? 'star' : 'stars'} and up`,
    suffix: '& up',
  },
)

const emit = defineEmits<{
  /** Fires with the minimum star count the user picks, or `0` when they clear it. */
  change: [value: number]
}>()

const filledStars = computed(() => Math.max(0, Math.min(props.value, props.max)))

function handleSelect(val: AcceptableValue | AcceptableValue[]) {
  const raw = Array.isArray(val) ? val[0] : val
  const v = typeof raw === 'string' ? raw : ''
  emit('change', v ? Number(v) : 0)
}
</script>

<template>
  <FilterField data-slot="filter-rating" :disabled="disabled" :class="['fui-filter-rating', props.class]">
    <FilterFieldTitle v-if="label">
      {{ label }}
    </FilterFieldTitle>
    <ToggleGroupRoot type="single" :model-value="value > 0 ? String(value) : ''" class="flex items-center gap-1" @update:model-value="handleSelect">
      <ToggleGroupItem
        v-for="i in max"
        :key="i"
        :value="String(i)"
        :disabled="disabled"
        data-slot="filter-rating-star"
        class="fui-filter-rating-star shrink-0 cursor-pointer outline-none transition-colors focus-visible:fui-focus-ring disabled:pointer-events-none disabled:fui-disabled"
        :aria-label="starLabel(i)"
      >
        <RatingIcon size="lg" :empty="i > filledStars" />
      </ToggleGroupItem>
      <div v-if="filledStars > 0" class="ms-1.5">
        <span data-slot="filter-rating-suffix" class="fui-filter-meta">{{ suffix }}</span>
      </div>
    </ToggleGroupRoot>
  </FilterField>
</template>
```

`components/ui/filter/FilterReset.vue`

```vue
<script lang="ts">
/** Lets the user clear all filters at once. */
export default {}
</script>

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

import type { HTMLAttributes } from 'vue'
import { inject } from 'vue'
import { Button } from '@library/components/ui/button'

const props = withDefaults(
  defineProps<{
    class?: HTMLAttributes['class']
    /** Shown on the reset button; the default slot replaces it. */
    label?: string
  }>(),
  { label: 'Reset' },
)

const emit = defineEmits<{
  /** Fires when the user clicks the reset button, after the surrounding filter's own reset runs. */
  reset: []
}>()

const injectedReset = inject<(() => void) | undefined>('filter-reset', undefined)

function handleReset() {
  if (injectedReset) {
    injectedReset()
  }
  emit('reset')
}
</script>

<template>
  <div data-slot="filter-reset" :class="['fui-filter-reset-root', props.class]">
    <Button variant="outline" color="neutral" size="sm" class="fui-filter-reset" @click="handleReset">
      <RotateCcwIcon data-icon class="me-3" />
      <slot>{{ props.label }}</slot>
    </Button>
  </div>
</template>
```

`components/ui/filter/FilterSlider.vue`

```vue
<script lang="ts">
/** Lets the user limit a numeric value to a range with a slider. */
export default {}
</script>

<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { computed, inject, ref, useId, watch } from 'vue'
import { Slider } from '@library/components/ui/slider'
import { FILTER_GROUP_LABEL } from './context'
import FilterField from './FilterField.vue'
import FilterFieldDescription from './FilterFieldDescription.vue'
import FilterFieldTitle from './FilterFieldTitle.vue'

const props = withDefaults(
  defineProps<{
    class?: HTMLAttributes['class']
    /** Shown as the filter's title. */
    label?: string
    /** Shown as a hint below the title. */
    description?: string
    /** Names the slider for screen readers when its title sits outside it, e.g. on an accordion trigger or a popover button. */
    ariaLabel?: string
    /** Lowest value on the slider. */
    min?: number
    /** Highest value on the slider. */
    max?: number
    /** Amount the slider moves per step. */
    step?: number
    /** The selected range; with only `to`, a single handle sets the highest value. */
    value?: { from?: number; to?: number }
    /** Greys out the slider so the shopper cannot change it. */
    disabled?: boolean
    /** Formats each value in the readout next to the title, e.g. adding a unit; the plain number when left out. */
    formatValue?: (value: number) => string
    /** Builds the text shown for a single-handle maximum from the formatted value. */
    upTo?: (value: string) => string
  }>(),
  {
    upTo: (value: string) => `Up to ${value}`,
    min: 0,
    max: 1000,
    step: 1,
    disabled: false,
  },
)

const emit = defineEmits<{
  /** Fires with the selected range as the user moves the slider; with a single handle, only `to`. */
  change: [value: { from?: number; to?: number }]
}>()

const sliderValue = ref<number[]>(computeSliderValue())

function computeSliderValue(): number[] {
  if (props.value?.from !== undefined && props.value?.to !== undefined) {
    return [props.value.from, props.value.to]
  }
  if (props.value?.to !== undefined) {
    return [props.value.to]
  }
  return [props.max ?? 1000]
}

watch(
  () => props.value,
  () => {
    sliderValue.value = computeSliderValue()
  },
  { deep: true },
)

function handleUpdate(val: number[] | undefined) {
  if (!val) return
  sliderValue.value = val
  if (val.length === 2) {
    emit('change', { from: val[0]!, to: val[1]! })
  } else {
    emit('change', { to: val[0]! })
  }
}

const titleId = useId()
// Without its own title, the thumbs take the name of the group the slider sits in.
const groupLabel = inject(FILTER_GROUP_LABEL, undefined)

const displayValue = computed(() => {
  const fmt = props.formatValue ?? ((v: number) => String(v))
  if (sliderValue.value.length === 2) {
    return `${fmt(sliderValue.value[0]!)} – ${fmt(sliderValue.value[1]!)}`
  }
  return props.upTo(fmt(sliderValue.value[0]!))
})
</script>

<template>
  <FilterField data-slot="filter-slider" :disabled="disabled" :class="['fui-filter-slider', props.class]">
    <div class="flex items-baseline justify-between gap-2">
      <FilterFieldTitle v-if="label" :id="titleId">
        {{ label }}
      </FilterFieldTitle>
      <span data-slot="filter-slider-value" class="fui-filter-meta shrink-0 whitespace-nowrap tabular-nums">
        {{ displayValue }}
      </span>
    </div>
    <FilterFieldDescription v-if="description">
      {{ description }}
    </FilterFieldDescription>
    <Slider
      :model-value="sliderValue"
      :min="min"
      :max="max"
      :step="step"
      :disabled="disabled"
      :format="formatValue"
      :aria-labelledby="label ? titleId : undefined"
      :aria-label="label ? undefined : (ariaLabel ?? groupLabel)"
      @update:model-value="handleUpdate"
    />
  </FilterField>
</template>
```

`components/ui/filter/FilterSwatch.vue`

```vue
<script lang="ts">
/** Lists colour options as swatches that the user can toggle. */
export default {}
</script>

<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cva } from 'class-variance-authority'
import { computed } from 'vue'
import type { AcceptableValue } from 'reka-ui'
import type { SwatchShape, SwatchSize } from '@library/components/ui/swatch'
import { SwatchGroup, SwatchGroupItem, type SwatchGroupOrientation } from '@library/components/ui/swatch-group'

export interface FilterSwatchOption {
  value: string
  hex: string
  label?: string
  count?: number
  selected?: boolean
  disabled?: boolean
}

const filterSwatch = cva('fui-filter-swatch', {
  variants: { orientation: { horizontal: 'fui-filter-swatch-horizontal', vertical: 'fui-filter-swatch-vertical' } },
  defaultVariants: { orientation: 'vertical' },
})

const props = withDefaults(
  defineProps<{
    class?: HTMLAttributes['class']
    /** The colours to list, each with its hex, label, result count and selected state. */
    options: FilterSwatchOption[]
    /** Whether the shopper can select one colour or several. */
    type?: 'single' | 'multiple'
    /** Disables every swatch. */
    disabled?: boolean
    /** The swatch corners: rounded or fully round. */
    shape?: SwatchShape
    /** The swatch diameter. */
    size?: SwatchSize
    /** A scrolling column of swatches with their labels, or a row of swatches. */
    orientation?: SwatchGroupOrientation
    /** Classes for each swatch's label; unused for options that carry a count. */
    labelClass?: HTMLAttributes['class']
  }>(),
  {
    type: 'multiple',
    disabled: false,
    shape: 'pill',
    size: 'xs',
    orientation: 'vertical',
  },
)

const emit = defineEmits<{
  /** Fires with a colour's value each time the user selects or deselects it. */
  toggle: [value: string]
}>()

const selectedValues = computed(() => props.options.filter((o) => o.selected).map((o) => o.value))

function handleChange(newValues: AcceptableValue | AcceptableValue[]) {
  const raw = Array.isArray(newValues) ? newValues : [newValues]
  const values = raw.filter((v): v is string => typeof v === 'string')
  const oldSet = new Set(selectedValues.value)
  const newSet = new Set(values)
  for (const v of newSet) {
    if (!oldSet.has(v)) emit('toggle', v)
  }
  for (const v of oldSet) {
    if (!newSet.has(v)) emit('toggle', v)
  }
}
</script>

<template>
  <SwatchGroup
    :type="type"
    :shape="shape"
    :size="size"
    :orientation="orientation"
    :label-class="labelClass"
    data-slot="filter-swatch"
    :model-value="selectedValues"
    :disabled="disabled"
    :class="[filterSwatch({ orientation }), orientation === 'vertical' && 'max-h-52 overflow-y-auto', props.class]"
    @update:model-value="handleChange"
  >
    <SwatchGroupItem v-for="opt in options" :key="opt.value" :value="opt.value" :hex="opt.hex" :label="opt.count === undefined ? opt.label : undefined" :disabled="opt.disabled">
      <template v-if="opt.count !== undefined" #label>
        <span class="flex flex-1 items-center justify-between gap-2">
          <span class="fui-filter-swatch-text">{{ opt.label }}</span>
          <span data-slot="filter-swatch-count" class="fui-filter-meta tabular-nums">{{ opt.count }}</span>
        </span>
      </template>
    </SwatchGroupItem>
  </SwatchGroup>
</template>
```

`components/ui/filter/FilterSwitch.vue`

```vue
<script lang="ts">
/** Lets the user turn a yes-or-no filter on or off. */
export default {}
</script>

<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { useId } from 'vue'
import { Switch } from '@library/components/ui/switch'
import FilterField from './FilterField.vue'
import FilterFieldContent from './FilterFieldContent.vue'
import FilterFieldDescription from './FilterFieldDescription.vue'
import FilterFieldLabel from './FilterFieldLabel.vue'

const props = withDefaults(
  defineProps<{
    class?: HTMLAttributes['class']
    /** Whether the filter is on. */
    value?: boolean
    /** Shown next to the switch, e.g. `In stock only`. */
    label: string
    /** Shown as a hint below the label. */
    description?: string
    /** Greys out the switch so the shopper cannot change it. */
    disabled?: boolean
  }>(),
  {
    value: false,
    disabled: false,
  },
)

const emit = defineEmits<{
  /** Fires with the new on or off state when the user flips the switch. */
  change: [value: boolean]
}>()

const switchId = useId()
</script>

<template>
  <FilterField data-slot="filter-switch" orientation="horizontal" :disabled="disabled" :class="['fui-filter-switch', props.class]">
    <FilterFieldContent>
      <FilterFieldLabel :for="switchId">{{ label }}</FilterFieldLabel>
      <FilterFieldDescription v-if="description">
        {{ description }}
      </FilterFieldDescription>
    </FilterFieldContent>
    <Switch :id="switchId" :model-value="value" :disabled="disabled" @update:model-value="emit('change', $event === true)" />
  </FilterField>
</template>
```

`components/ui/filter/FilterTrigger.vue`

```vue
<script lang="ts">
/** Opens and closes a filter group, showing its label and count of active options. */
export default {}
</script>

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

import type { HTMLAttributes } from 'vue'
import { Button } from '@library/components/ui/button'

const props = defineProps<{
  class?: HTMLAttributes['class']
  /** Shown on the trigger, e.g. `Brand` or `Size`. */
  label: string
  /** How many options in the group are active; shown as a count when above zero. */
  activeCount?: number
}>()
</script>

<template>
  <Button data-slot="filter-trigger" variant="outline" color="neutral" :class="['fui-filter-trigger', props.class]">
    <span data-slot="filter-trigger-label" class="flex-1 text-start">{{ label }}</span>
    <span v-if="activeCount" data-slot="filter-trigger-count" class="fui-filter-trigger-count ms-auto inline-flex shrink-0 items-center justify-center">
      {{ activeCount }}
    </span>
    <ChevronDownIcon data-icon class="transition-transform duration-200 group-aria-expanded/button:rotate-180" />
  </Button>
</template>
```

`components/ui/filter/context.ts`

```ts
import type { ComputedRef, InjectionKey } from 'vue'

export const FILTER_GROUP_LABEL: InjectionKey<ComputedRef<string>> = Symbol('filter-group-label')
```

## API of the items the example uses

### Tag Group

[Full docs](https://ui.frontic.com/components/ui/tag-group.md) · `npx @frontic/ui add tag-group`

#### TagGroup

Groups tags the user moves through with the arrow keys, and can select or remove. Renders a `<div>`.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `modelValue` | `AcceptableValue \| AcceptableValue[]`: `null \| string \| number \| bigint \| Record<string, any> \| AcceptableValue[]` |  | The controlled value of the selected tag(s). Can be binded with `v-model`. An array when `selectionMode` is `multiple`. |
| `defaultValue` | `AcceptableValue \| AcceptableValue[]`: `null \| string \| number \| bigint \| Record<string, any> \| AcceptableValue[]` |  | The value of the selected tag(s) when initially rendered. Use when you do not need to control the selection. |
| `selectionMode` | `TagGroupSelectionMode`: `'single' \| 'multiple' \| 'none'` | `'none'` | The type of selection that is allowed. Tags are not selectable when `none`. |
| `disallowEmptySelection` | `boolean` | `false` | When `true`, the user cannot deselect the last selected tag. |
| `escapeKeyBehavior` | `'none' \| 'clearSelection'` | `'clearSelection'` | Whether pressing <kbd>Escape</kbd> clears the selection. |
| `disabled` | `boolean` | `false` | When `true`, prevents the user from interacting with the tag group and all its tags. |
| `dir` | `Direction`: `'ltr' \| 'rtl'` |  | The reading direction of the tag group when applicable.<br><br>If omitted, inherits globally from `ConfigProvider` or assumes LTR (left-to-right) reading mode. |
| `loop` | `boolean` | `true` | When `true`, keyboard navigation will loop from last tag to first, and vice versa. |
| `by` | `string \| ((a: AcceptableValue, b: AcceptableValue) => boolean)`: `string \| (a: AcceptableValue, b: AcceptableValue): boolean` |  | Use this to compare objects by a particular field, or pass your own comparison function for complete control over how objects are compared. |
| `asChild` | `boolean` | `false` | Change the default rendered element for the one passed as a child, merging their props and behavior.<br><br>Read our [Composition](https://www.reka-ui.com/docs/guides/composition) guide for more details. |
| `as` | `AsTag \| Component`: `ComponentOptions<any, any, any, ComputedOptions, MethodOptions, any, any, any, string, {}, {}, string, {}, {}, {}, string, ComponentProvideOptions> \| (props: any, ctx: Omit<{ attrs: Attrs; slots: Readonly<InternalSlots>; emit: (event: string, ...args: any[]) => void; expose: <Exposed extends Record<string, any> = Record<string, any>>(exposed?: Exposed \| undefined) => void; }, 'expose'>): any \| ComponentPublicInstanceConstructor<any, any, any, any, ComputedOptions, MethodOptions> \| 'a' \| 'button' \| 'div' \| 'form' \| 'h2' \| 'h3' \| 'img' \| 'input' \| 'label' \| 'li' \| 'nav' \| 'ol' \| 'p' \| 'span' \| 'svg' \| 'ul' \| 'template' \| {} & string` | `'div'` | The element or component this component should render as. Can be overwritten by `asChild`. |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |

| Event | Payload | Description |
| --- | --- | --- |
| `update:modelValue` | `value: AcceptableValue \| AcceptableValue[] \| undefined` | Event handler called when the selection changes. |
| `remove` | `values: AcceptableValue[]` | Event handler called when the user removes tags, with the values to remove. Tags are only removable when this event has a listener; remove the values from your own list to remove the tags. |

| Slot | Slot props | Description |
| --- | --- | --- |
| `default` | `{ modelValue: AcceptableValue \| AcceptableValue[] \| undefined; }` | Holds the tags; gets the selected value as `modelValue`. |

| Data attribute | Values | Description |
| --- | --- | --- |
| `data-disabled` |  | Present while the part is disabled. |
| `data-empty` |  | Present while no option matches the search. |

#### TagGroupItem

One tag: its text and, when removable, a delete button. Renders a `<div>`.

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

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` (required) | `AcceptableValue`: `null \| string \| number \| bigint \| Record<string, any>` |  | The unique value of the tag. |
| `disabled` | `boolean` | `false` | When `true`, prevents the user from interacting with the tag. |
| `textValue` | `string` |  | A string representation of the tag's contents, used for typeahead and as the tag's accessible name. Defaults to the text of `TagGroupItemText`. |
| `asChild` | `boolean` | `false` | Change the default rendered element for the one passed as a child, merging their props and behavior.<br><br>Read our [Composition](https://www.reka-ui.com/docs/guides/composition) guide for more details. |
| `as` | `AsTag \| Component`: `ComponentOptions<any, any, any, ComputedOptions, MethodOptions, any, any, any, string, {}, {}, string, {}, {}, {}, string, ComponentProvideOptions> \| (props: any, ctx: Omit<{ attrs: Attrs; slots: Readonly<InternalSlots>; emit: (event: string, ...args: any[]) => void; expose: <Exposed extends Record<string, any> = Record<string, any>>(exposed?: Exposed \| undefined) => void; }, 'expose'>): any \| ComponentPublicInstanceConstructor<any, any, any, any, ComputedOptions, MethodOptions> \| 'a' \| 'button' \| 'div' \| 'form' \| 'h2' \| 'h3' \| 'img' \| 'input' \| 'label' \| 'li' \| 'nav' \| 'ol' \| 'p' \| 'span' \| 'svg' \| 'ul' \| 'template' \| {} & string` | `'div'` | The element or component this component should render as. Can be overwritten by `asChild`. |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |

| Slot | Slot props | Description |
| --- | --- | --- |
| `default` | `{ selected: boolean; disabled: boolean; }` | Holds the text and delete button; gets whether the tag is `selected` and `disabled`. |

| Data attribute | Values | Description |
| --- | --- | --- |
| `data-disabled` |  | Present while the part is disabled. |
| `data-state` | `'checked' \| 'unchecked'` | The state the part is in, one value per state. |

#### TagGroupItemDelete

Lets the user remove its tag; the group emits `remove`. Renders a `<button>`.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `disabled` | `boolean` | `false` | When `true`, prevents the user from interacting with the delete button. |
| `asChild` | `boolean` | `false` | Change the default rendered element for the one passed as a child, merging their props and behavior.<br><br>Read our [Composition](https://www.reka-ui.com/docs/guides/composition) guide for more details. |
| `as` | `AsTag \| Component`: `ComponentOptions<any, any, any, ComputedOptions, MethodOptions, any, any, any, string, {}, {}, string, {}, {}, {}, string, ComponentProvideOptions> \| (props: any, ctx: Omit<{ attrs: Attrs; slots: Readonly<InternalSlots>; emit: (event: string, ...args: any[]) => void; expose: <Exposed extends Record<string, any> = Record<string, any>>(exposed?: Exposed \| undefined) => void; }, 'expose'>): any \| ComponentPublicInstanceConstructor<any, any, any, any, ComputedOptions, MethodOptions> \| 'a' \| 'button' \| 'div' \| 'form' \| 'h2' \| 'h3' \| 'img' \| 'input' \| 'label' \| 'li' \| 'nav' \| 'ol' \| 'p' \| 'span' \| 'svg' \| 'ul' \| 'template' \| {} & string` | `'button'` | The element or component this component should render as. Can be overwritten by `asChild`. |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |

| Data attribute | Values | Description |
| --- | --- | --- |
| `data-disabled` |  | Present while the part is disabled. |

#### TagGroupItemText

Shows the text of one tag. Renders a `<span>`.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `asChild` | `boolean` | `false` | Change the default rendered element for the one passed as a child, merging their props and behavior.<br><br>Read our [Composition](https://www.reka-ui.com/docs/guides/composition) guide for more details. |
| `as` | `AsTag \| Component`: `ComponentOptions<any, any, any, ComputedOptions, MethodOptions, any, any, any, string, {}, {}, string, {}, {}, {}, string, ComponentProvideOptions> \| (props: any, ctx: Omit<{ attrs: Attrs; slots: Readonly<InternalSlots>; emit: (event: string, ...args: any[]) => void; expose: <Exposed extends Record<string, any> = Record<string, any>>(exposed?: Exposed \| undefined) => void; }, 'expose'>): any \| ComponentPublicInstanceConstructor<any, any, any, any, ComputedOptions, MethodOptions> \| 'a' \| 'button' \| 'div' \| 'form' \| 'h2' \| 'h3' \| 'img' \| 'input' \| 'label' \| 'li' \| 'nav' \| 'ol' \| 'p' \| 'span' \| 'svg' \| 'ul' \| 'template' \| {} & string` | `'span'` | The element or component this component should render as. Can be overwritten by `asChild`. |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |
