---
name: category-grid
title: Category Grid
url: https://ui.frontic.com/components/templates/category-grid.md
tier: templates
install: npx @frontic/ui add category-grid
files:
  - components/templates/category-grid/CategoryGrid.vue
  - components/templates/category-grid/index.ts
  - components/templates/category-grid/types.ts
registryDependencies:
  - accordion
  - badge
  - breadcrumb
  - button
  - empty
  - filter
  - filter-model
  - header-shop
  - navigation-shop
  - pagination
  - product-card-gallery
  - scroll-area
  - select
  - separator
  - sheet
  - text
dependencies:
  - "@lucide/vue"
  - "@vueuse/core"
---

# Category Grid

Category page under the shop header: breadcrumb, title and description, result count and sort, a filter sidebar that becomes a sheet on small screens, a product gallery grid and pagination.

## Install

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

Built from: [Accordion](https://ui.frontic.com/components/ui/accordion.md), [Badge](https://ui.frontic.com/components/ui/badge.md), [Breadcrumb](https://ui.frontic.com/components/ui/breadcrumb.md), [Button](https://ui.frontic.com/components/ui/button.md), [Empty](https://ui.frontic.com/components/ui/empty.md), [Filter](https://ui.frontic.com/components/ui/filter.md), [Filter Model](https://ui.frontic.com/lib/filter-model.md), [Header Shop](https://ui.frontic.com/components/templates/header-shop.md), [Navigation Shop](https://ui.frontic.com/components/sections/navigation-shop.md), [Pagination](https://ui.frontic.com/components/ui/pagination.md), [Product Card Gallery](https://ui.frontic.com/components/sections/product-card-gallery.md), [Scroll Area](https://ui.frontic.com/components/ui/scroll-area.md), [Select](https://ui.frontic.com/components/ui/select.md), [Separator](https://ui.frontic.com/components/ui/separator.md), [Sheet](https://ui.frontic.com/components/ui/sheet.md), [Text](https://ui.frontic.com/lib/text.md)

npm packages: `@lucide/vue`, `@vueuse/core`

## Examples

```vue
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { Card } from '@/components/ui/card'
import { CategoryGrid, type GridHeader, type Product, type UiFilter, type UiFilterValue, type UiSort } from '@/components/templates/category-grid'

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

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

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

const header: GridHeader = {
  announcement: 'Free shipping and returns on all orders',
  logoText: 'BASECAMP',
  cartCount: 3,
  categories: [
    { name: 'Women', columns: [{ name: 'Winter', links: ['Jackets', 'Beanies', 'Goggles', 'Helmets', 'Gloves'].map((name) => ({ name, href: '#' })) }] },
    { name: 'Men', columns: [{ name: 'Winter', links: ['Jackets', 'Beanies', 'Goggles', 'Helmets'].map((name) => ({ name, href: '#' })) }] },
    { name: 'Equipment', columns: [{ name: 'Equipment', links: [{ name: 'Backpacks', href: '#' }] }] },
  ],
}

const breadcrumbs = [{ label: 'Home', href: '#' }, { label: 'Men', href: '#' }, { label: 'Jackets' }]

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

const state = reactive({
  colours: new Set<string>(),
  sizes: new Set<string>(),
  price: {} as { from?: number; to?: number },
  sort: 'default',
})
const page = ref(1)

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

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

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

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

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

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

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

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

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

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

function reset() {
  state.colours.clear()
  state.sizes.clear()
  state.price = {}
}
</script>

<template>
  <Card class="gap-0 py-0">
    <CategoryGrid
      v-model:page="page"
      :header="header"
      :breadcrumbs="breadcrumbs"
      title="Jackets"
      description="Parkas, softshells and ski jackets for cold days on the mountain and in town, every one in sizes S to XXL."
      :products="products"
      :total-results="results.length"
      :per-page="PER_PAGE"
      :filters="filters"
      :active-filters="activeFilters"
      :sort-options="sortOptions"
      :active-sorting="state.sort"
      :swatch-colors="swatchColors"
      @add-filter="addFilter"
      @remove-filter="removeFilter"
      @sort-result="(sort?: string) => (state.sort = sort ?? 'default')"
      @reset="reset"
    />
  </Card>
</template>
```

The example also uses `card`, which `add category-grid` leaves out: `npx @frontic/ui add card`.

## API reference

### CategoryGrid

Shows a category page with header, filters, sorting and a grid of products. Renders a `<div>`.

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

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `header` | `GridHeader` | `{}` | Props for the HeaderShop above the page; the `header` slot replaces it. |
| `breadcrumbs` | `GridCrumb[]` | `[]` | The trail above the heading; the last crumb shows as the current page. |
| `title` | `string` | `''` | Shown as the page heading. |
| `description` | `string` | `''` | Shown under the page heading; hidden when empty. |
| `products` | `Product[]` | `[]` | The current page's products, shown as cards. |
| `totalResults` | `number` | `0` | Number of products matching the filters; sets the page count and the results labels. |
| `perPage` | `number` | `12` | Products per page; sets the page count and the range shown under the grid. |
| `filters` | `UiFilter[]` | `[]` | The filters to offer; `type` picks the control: `boolean`, `rating`, `color`, `range` or an option list. |
| `activeFilters` | `Record<string, unknown>` | `{}` | Applied values by filter key for switch, rating and range filters; list options carry `selected` themselves. |
| `sortOptions` | `UiSort[]` | `[]` | The sort orders in the sort select. |
| `activeSorting` | `string` | `'default'` | The `value` of the selected sort option. |
| `swatchColors` | `Record<string, string>` | `{ ...DEFAULT_SWATCH_COLORS }` | Swatch colour per colour-filter value, keyed lowercase; values without one show grey. |
| `currency` | `string` | `'EUR'` | ISO 4217 code the price filter shows its range in. |
| `locale` | `string` |  | BCP 47 locale the prices are formatted in; the ConfigProvider's when left out. |
| `resultsLabel` | `((count: number) => string)`: `(count: number): string` | ``${count} ${count === 1 ? 'product' : 'products'}`` | Builds the results count next to the sort select from `totalResults`. |
| `rangeLabel` | `((from: number, to: number, total: number) => string)`: `(from: number, to: number, total: number): string` | ``Showing ${from}–${to} of ${total} ${total === 1 ? 'product' : 'products'}`` | Builds the line under the grid from the first and last product shown and `totalResults`. |
| `sortPlaceholder` | `string` | `'Sort by'` | Shown in the sort select while no option is selected. |
| `filtersLabel` | `string` | `'Filters'` | Shown on the button that opens the filter sheet on small screens, and as the sheet's heading. |
| `showResultsLabel` | `((count: number) => string)`: `(count: number): string` | ``Show ${count} ${count === 1 ? 'product' : 'products'}`` | Builds the label of the button that closes the filter sheet from `totalResults`. |
| `clearAllLabel` | `string` | `'Clear all'` | Shown on the link next to the active filters and on the empty state's button; both clear every filter. |
| `removeLabel` | `((label: string) => string)`: `(label: string): string` | ``Remove ${label}`` | Builds the screen-reader name of an active filter's remove button from the option's label. |
| `emptyTitle` | `string` | `'No products match these filters'` | Shown as the heading when no product matches the filters. |
| `emptyText` | `string` | `'Remove a filter or clear them all to see more.'` | Shown under the heading when no product matches the filters. |
| `previousLabel` | `string` | `'Previous'` | Shown on the pagination's previous-page button. |
| `nextLabel` | `string` | `'Next'` | Shown on the pagination's next-page button. |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |
| `page` | `number` | `1` | The page of results on show, from 1. |

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

| Slot | Slot props | Description |
| --- | --- | --- |
| `header` |  | Replaces the shop header above the grid. |

## Exports

```ts
import { type Product, type NavigationCategory, type NavigationLink, type UiFilter, type UiFilterOption, type UiFilterStats, type UiFilterValue, type UiSort, type GridHeader, type GridCrumb } from '@/components/templates/category-grid'
```

- `Product`: from [Product Card Gallery](https://ui.frontic.com/components/sections/product-card-gallery.md)
- `NavigationCategory`, `NavigationLink`: from [Navigation Shop](https://ui.frontic.com/components/sections/navigation-shop.md)
- `UiFilter`, `UiFilterOption`, `UiFilterStats`, `UiFilterValue`, `UiSort`: from [Filter Model](https://ui.frontic.com/lib/filter-model.md)
- `GridHeader`: below
- `GridCrumb`: below

```ts
interface GridHeader {
  announcement?: string
  logoText?: string
  logoSrc?: string
  homeHref?: string
  categories?: NavigationCategory[]
  pages?: NavigationLink[]
  cartCount?: number
}

interface GridCrumb {
  label: string
  href?: string
}
```

## Styling

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

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

## Source

`components/templates/category-grid/CategoryGrid.vue`

```vue
<script lang="ts">
/** Shows a category page with header, filters, sorting and a grid of products. */
export default {}
</script>

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

import type { HTMLAttributes } from 'vue'
import type { GridCrumb, GridHeader, Product, UiFilter, UiFilterValue, UiSort } from './types'
import { computed, ref } from 'vue'
import { createReusableTemplate } from '@vueuse/core'
import { activeBoolean, activeFilterCount, activeRange, activeRating, DEFAULT_SWATCH_COLORS, filterSwatches } from '@library/lib/filter-model'
import { pageRange } from '@library/lib/text'
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@library/components/ui/accordion'
import { Badge } from '@library/components/ui/badge'
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from '@library/components/ui/breadcrumb'
import { Button } from '@library/components/ui/button'
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from '@library/components/ui/empty'
import { Filter, FilterList, FilterPrice, FilterRating, FilterSwatch, FilterSwitch } from '@library/components/ui/filter'
import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationNext, PaginationPrevious } from '@library/components/ui/pagination'
import { ScrollArea } from '@library/components/ui/scroll-area'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@library/components/ui/select'
import { Separator } from '@library/components/ui/separator'
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from '@library/components/ui/sheet'
import { HeaderShop } from '@library/components/templates/header-shop'
import { ProductCardGallery } from '@library/components/sections/product-card-gallery'

const props = withDefaults(
  defineProps<{
    /** Props for the HeaderShop above the page; the `header` slot replaces it. */
    header?: GridHeader
    /** The trail above the heading; the last crumb shows as the current page. */
    breadcrumbs?: GridCrumb[]
    /** Shown as the page heading. */
    title?: string
    /** Shown under the page heading; hidden when empty. */
    description?: string
    /** The current page's products, shown as cards. */
    products?: Product[]
    /** Number of products matching the filters; sets the page count and the results labels. */
    totalResults?: number
    /** Products per page; sets the page count and the range shown under the grid. */
    perPage?: number
    /** The filters to offer; `type` picks the control: `boolean`, `rating`, `color`, `range` or an option list. */
    filters?: UiFilter[]
    /** Applied values by filter key for switch, rating and range filters; list options carry `selected` themselves. */
    activeFilters?: Record<string, unknown>
    /** The sort orders in the sort select. */
    sortOptions?: UiSort[]
    /** The `value` of the selected sort option. */
    activeSorting?: string
    /** Swatch colour per colour-filter value, keyed lowercase; values without one show grey. */
    swatchColors?: Record<string, string>
    /** ISO 4217 code the price filter shows its range in. */
    currency?: string
    /** BCP 47 locale the prices are formatted in; the ConfigProvider's when left out. */
    locale?: string
    /** Builds the results count next to the sort select from `totalResults`. */
    resultsLabel?: (count: number) => string
    /** Builds the line under the grid from the first and last product shown and `totalResults`. */
    rangeLabel?: (from: number, to: number, total: number) => string
    /** Shown in the sort select while no option is selected. */
    sortPlaceholder?: string
    /** Shown on the button that opens the filter sheet on small screens, and as the sheet's heading. */
    filtersLabel?: string
    /** Builds the label of the button that closes the filter sheet from `totalResults`. */
    showResultsLabel?: (count: number) => string
    /** Shown on the link next to the active filters and on the empty state's button; both clear every filter. */
    clearAllLabel?: string
    /** Builds the screen-reader name of an active filter's remove button from the option's label. */
    removeLabel?: (label: string) => string
    /** Shown as the heading when no product matches the filters. */
    emptyTitle?: string
    /** Shown under the heading when no product matches the filters. */
    emptyText?: string
    /** Shown on the pagination's previous-page button. */
    previousLabel?: string
    /** Shown on the pagination's next-page button. */
    nextLabel?: string
    class?: HTMLAttributes['class']
  }>(),
  {
    header: () => ({}),
    breadcrumbs: () => [],
    title: '',
    description: '',
    products: () => [],
    totalResults: 0,
    perPage: 12,
    filters: () => [],
    activeFilters: () => ({}),
    sortOptions: () => [],
    activeSorting: 'default',
    swatchColors: () => ({ ...DEFAULT_SWATCH_COLORS }),
    currency: 'EUR',
    resultsLabel: (count: number) => `${count} ${count === 1 ? 'product' : 'products'}`,
    rangeLabel: (from: number, to: number, total: number) => `Showing ${from}–${to} of ${total} ${total === 1 ? 'product' : 'products'}`,
    sortPlaceholder: 'Sort by',
    filtersLabel: 'Filters',
    showResultsLabel: (count: number) => `Show ${count} ${count === 1 ? 'product' : 'products'}`,
    clearAllLabel: 'Clear all',
    removeLabel: (label: string) => `Remove ${label}`,
    emptyTitle: 'No products match these filters',
    emptyText: 'Remove a filter or clear them all to see more.',
    previousLabel: 'Previous',
    nextLabel: 'Next',
  },
)

/** The page of results on show, from 1. */
const page = defineModel<number>('page', { default: 1 })

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

defineSlots<{
  /** Replaces the shop header above the grid. */
  header?: () => unknown
}>()

const [DefineFilters, ReuseFilters] = createReusableTemplate()
const sheetOpen = ref(false)

const collapsible = computed(() => props.filters.filter((f) => f.type !== 'boolean'))
const switches = computed(() => props.filters.filter((f) => f.type === 'boolean'))
const opened = ref(collapsible.value.filter((f, i) => i < 2 || f.options.some((o) => o.selected)).map((f) => f.key))

const selectedCount = (filter: UiFilter) => filter.options.filter((o) => o.selected).length || undefined
const swatches = (filter: UiFilter) => filterSwatches(filter, props.swatchColors)

function rangeValue(filter: UiFilter) {
  const range = activeRange(props.activeFilters, filter.key)
  return {
    from: range.from ?? filter.data?.total.min ?? 0,
    to: range.to ?? filter.data?.total.max ?? 1000,
  }
}

const activeSwitch = (filter: UiFilter) => activeBoolean(props.activeFilters, filter.key)

const chips = computed(() => [
  ...props.filters
    .filter((f) => f.type !== 'boolean' && f.type !== 'rating' && f.type !== 'range')
    .flatMap((f) =>
      f.options
        .filter((o) => o.selected)
        .map((o) => ({
          label: o.option,
          field: f.key,
          value: o.value as string | undefined,
        })),
    ),
  ...switches.value.filter(activeSwitch).map((f) => ({ label: f.label, field: f.key, value: undefined })),
])
const activeCount = computed(() => activeFilterCount(props.filters, props.activeFilters))

const pages = computed(() => Math.max(1, Math.ceil(props.totalResults / props.perPage)))
const shown = computed(() => pageRange(page.value, props.perPage, props.totalResults))

function changed() {
  page.value = 1
}

function toggle(filter: UiFilter, value: string) {
  const option = filter.options.find((o) => o.value === value)
  if (option?.selected) emit('removeFilter', filter.key, value)
  else emit('addFilter', filter.key, value)
  changed()
}

function set(filter: UiFilter, value: UiFilterValue | undefined) {
  if (value === undefined) emit('removeFilter', filter.key)
  else emit('addFilter', filter.key, value)
  changed()
}

function remove(field: string, value?: string) {
  emit('removeFilter', field, value)
  changed()
}

function sort(value: unknown) {
  emit('sortResult', String(value) === 'default' ? undefined : String(value))
  changed()
}

function reset() {
  emit('reset')
  changed()
}
</script>

<template>
  <div :class="['fui-category-grid @container', props.class]">
    <DefineFilters>
      <div class="flex flex-col gap-4">
        <div v-if="chips.length" class="flex flex-wrap items-center gap-2">
          <Badge v-for="chip in chips" :key="`${chip.field}-${chip.value}`" variant="primary">
            {{ chip.label }}
            <button
              type="button"
              data-slot="category-grid-dismiss"
              class="fui-category-grid-dismiss inline-flex shrink-0 items-center justify-center outline-none focus-visible:fui-focus-ring"
              :aria-label="removeLabel(chip.label)"
              @click="remove(chip.field, chip.value)"
            >
              <XIcon class="size-3" />
            </button>
          </Badge>
          <button type="button" data-slot="clear-link" class="fui-clear-link outline-none focus-visible:fui-focus-ring" @click="reset">
            {{ clearAllLabel }}
          </button>
        </div>
        <Filter :reset="reset">
          <Accordion v-model="opened" type="multiple">
            <AccordionItem v-for="filter in collapsible" :key="filter.key" :value="filter.key">
              <AccordionTrigger>
                <span class="flex flex-1 items-center gap-2">
                  {{ filter.label }}
                  <Badge v-if="selectedCount(filter)" variant="primary" size="sm">{{ selectedCount(filter) }}</Badge>
                </span>
              </AccordionTrigger>
              <AccordionContent>
                <FilterPrice
                  v-if="filter.type === 'range'"
                  :aria-label="filter.label"
                  :currency="currency"
                  :locale="locale"
                  :precision="0"
                  :min="filter.data?.total.min ?? 0"
                  :max="filter.data?.total.max ?? 1000"
                  :step="10"
                  :value="rangeValue(filter)"
                  @change="set(filter, $event)"
                />
                <FilterRating v-else-if="filter.type === 'rating'" :value="activeRating(activeFilters, filter.key)" :max="5" @change="set(filter, $event > 0 ? { from: $event } : undefined)" />
                <FilterSwatch v-else-if="filter.type === 'color'" :options="swatches(filter)" @toggle="toggle(filter, $event)" />
                <FilterList v-else :options="filter.options" @toggle="toggle(filter, $event)" />
              </AccordionContent>
            </AccordionItem>
          </Accordion>
          <FilterSwitch v-for="filter in switches" :key="filter.key" class="pt-4" :value="activeSwitch(filter)" :label="filter.label" @change="set(filter, $event || undefined)" />
        </Filter>
      </div>
    </DefineFilters>

    <div class="relative">
      <slot name="header">
        <HeaderShop v-bind="header" />
      </slot>
    </div>

    <div class="mx-auto flex w-full max-w-384 flex-col gap-5 px-4 pt-5 pb-8 @2xl:px-6 @5xl:px-8">
      <Breadcrumb v-if="breadcrumbs.length">
        <BreadcrumbList>
          <template v-for="(crumb, index) in breadcrumbs" :key="crumb.label">
            <BreadcrumbSeparator v-if="index > 0" />
            <BreadcrumbItem>
              <BreadcrumbPage v-if="index === breadcrumbs.length - 1">{{ crumb.label }}</BreadcrumbPage>
              <BreadcrumbLink v-else :href="crumb.href">{{ crumb.label }}</BreadcrumbLink>
            </BreadcrumbItem>
          </template>
        </BreadcrumbList>
      </Breadcrumb>

      <div class="flex flex-col gap-4 @3xl:flex-row @3xl:items-end @3xl:justify-between @3xl:gap-8">
        <div class="flex max-w-2xl flex-col gap-2">
          <h1 data-slot="category-grid-heading" class="fui-category-grid-heading">
            {{ title }}
          </h1>
          <p v-if="description" data-slot="category-grid-description" class="fui-category-grid-description">
            {{ description }}
          </p>
        </div>
        <div class="flex shrink-0 items-center justify-between gap-3">
          <Sheet v-model:open="sheetOpen">
            <SheetTrigger as-child>
              <Button variant="outline" color="neutral" class="@3xl:hidden">
                <SlidersHorizontalIcon data-icon />
                {{ filtersLabel }}
                <Badge v-if="activeCount" size="sm">{{ activeCount }}</Badge>
              </Button>
            </SheetTrigger>
            <SheetContent side="bottom" class="max-h-17/20">
              <SheetHeader>
                <SheetTitle>{{ filtersLabel }}</SheetTitle>
              </SheetHeader>
              <ScrollArea class="min-h-0 flex-1">
                <div class="px-4 pb-4">
                  <ReuseFilters />
                </div>
              </ScrollArea>
              <SheetFooter>
                <Button @click="sheetOpen = false">{{ showResultsLabel(totalResults) }}</Button>
              </SheetFooter>
            </SheetContent>
          </Sheet>
          <div class="flex items-center gap-3">
            <span class="hidden @md:inline">
              <span data-slot="hint" class="fui-hint">{{ resultsLabel(totalResults) }}</span>
            </span>
            <Select :model-value="activeSorting" @update:model-value="sort">
              <SelectTrigger class="w-44">
                <SelectValue :placeholder="sortPlaceholder" />
              </SelectTrigger>
              <SelectContent>
                <SelectItem v-for="option in sortOptions" :key="option.key" :value="option.value">{{ option.label }}</SelectItem>
              </SelectContent>
            </Select>
          </div>
        </div>
      </div>

      <Separator />

      <div class="flex gap-10">
        <aside class="hidden w-64 shrink-0 @3xl:block">
          <ReuseFilters />
        </aside>

        <div class="@container flex min-w-0 flex-1 flex-col gap-8">
          <div v-if="products.length" class="grid grid-cols-1 gap-4 @md:grid-cols-2 @xl:grid-cols-3 @2xl:gap-6 @6xl:grid-cols-4">
            <ProductCardGallery v-for="(product, index) in products" :key="product.key ?? index" :product="product" :locale="locale" />
          </div>
          <Empty v-else>
            <EmptyHeader>
              <EmptyTitle>{{ emptyTitle }}</EmptyTitle>
              <EmptyDescription>{{ emptyText }}</EmptyDescription>
            </EmptyHeader>
            <EmptyContent>
              <Button variant="outline" color="neutral" @click="reset">{{ clearAllLabel }}</Button>
            </EmptyContent>
          </Empty>

          <div v-if="products.length" class="flex flex-col items-center gap-4 @2xl:flex-row @2xl:justify-between">
            <span v-if="shown" class="shrink-0">
              <span data-slot="hint" class="fui-hint">{{ rangeLabel(shown.from, Math.min(shown.to, shown.from + products.length - 1), totalResults) }}</span>
            </span>
            <Pagination v-if="pages > 1" v-model:page="page" :items-per-page="perPage" :total="totalResults" class="mx-0 w-auto">
              <PaginationContent v-slot="{ items }">
                <PaginationPrevious :label="previousLabel" />
                <template v-for="(item, index) in items" :key="index">
                  <PaginationItem v-if="item.type === 'page'" :value="item.value">{{ item.value }}</PaginationItem>
                  <PaginationEllipsis v-else :index="index" />
                </template>
                <PaginationNext :label="nextLabel" />
              </PaginationContent>
            </Pagination>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>
```

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

```ts
import type { NavigationCategory, NavigationLink } from '@library/components/sections/navigation-shop'

export type { Product } from '@library/components/sections/product-card-gallery'
export type { NavigationCategory, NavigationLink } from '@library/components/sections/navigation-shop'
export type { UiFilter, UiFilterOption, UiFilterStats, UiFilterValue, UiSort } from '@library/lib/filter-model'

/** The props the listing hands its HeaderShop. */
export interface GridHeader {
  announcement?: string
  logoText?: string
  logoSrc?: string
  homeHref?: string
  categories?: NavigationCategory[]
  pages?: NavigationLink[]
  cartCount?: number
}

export interface GridCrumb {
  label: string
  href?: string
}
```

## API of the items the example uses

### Card

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

#### Card

Groups related content, such as a product or an order, into one framed block. Renders a `<div>`.

`data-slot="card"` · `Card.vue`

Styling: `card`, which extends [`panel`](https://ui.frontic.com/DESIGN.md#panel) and [`surface-inset`](https://ui.frontic.com/DESIGN.md#surface-inset). Change them there and every part that extends them 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`. |
| `surface` | `'raised' \| 'bare'` | `'raised'` | Raised stands on its own with an edge, a shadow and an inset; bare sits flush inside another surface, a Card-rooted section inside a Card. |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |

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

#### CardAction

Holds a control placed at the end of the card header. Renders a `<div>`.

`data-slot="card-action"` · `CardAction.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. |

#### CardContent

Holds the main content of the card. Renders a `<div>`.

`data-slot="card-content"` · `CardContent.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. |

#### CardDescription

Shows secondary text below the card title. Renders a `<div>`.

`data-slot="card-description"` · `CardDescription.vue`

Styling: `card-description`, which extends [`part-description`](https://ui.frontic.com/DESIGN.md#part-description). 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`. |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |

#### CardFooter

Holds the closing content of the card, such as status or buttons. Renders a `<div>`.

`data-slot="card-footer"` · `CardFooter.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. |

#### CardHeader

Groups the title, description and action at the top of the card. Renders a `<div>`.

`data-slot="card-header"` · `CardHeader.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. |

#### CardImage

Shows a picture across the top of the card. Renders a `<div>`.

`data-slot="card-image"` · `CardImage.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. |

#### CardTitle

Names what the card is about. Renders a `<div>`.

`data-slot="card-title"` · `CardTitle.vue`

Styling: `card-title`, which extends [`part-title`](https://ui.frontic.com/DESIGN.md#part-title). 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`. |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |
