---
name: search
title: Search
url: https://ui.frontic.com/components/ui/search.md
tier: ui
install: npx @frontic/ui add search
files:
  - components/ui/search/Search.vue
  - components/ui/search/SearchEmpty.vue
  - components/ui/search/SearchGroup.vue
  - components/ui/search/SearchHighlight.vue
  - components/ui/search/SearchInput.vue
  - components/ui/search/SearchItem.vue
  - components/ui/search/SearchLoading.vue
  - components/ui/search/SearchOverlay.vue
  - components/ui/search/SearchQuery.vue
  - components/ui/search/SearchResults.vue
  - components/ui/search/SearchTag.vue
  - components/ui/search/index.ts
  - components/ui/search/keys.ts
registryDependencies:
  - skeleton
dependencies:
  - "@lucide/vue"
  - class-variance-authority
  - reka-ui
---

# Search

A search field whose panel offers products, categories and suggestions while the shopper types, with a "Search for" row that submits; inline under the field or in an overlay.

## Install

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

Built from: [Skeleton](https://ui.frontic.com/components/ui/skeleton.md)

Used by: [Search Suggestions](https://ui.frontic.com/components/sections/search-suggestions.md)

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

## Examples

```vue
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { Image } from '@/components/ui/image'
import { Price } from '@/components/ui/price'
import { Search, SearchEmpty, SearchGroup, SearchHighlight, SearchInput, SearchItem, SearchLoading, SearchQuery, SearchResults, SearchTag } from '@/components/ui/search'

const image = (name: string) => `https://static.frontic.com/cdn-cgi/image/width=160,format=auto/ui/products/${name}.jpg`
const eur = (amount: number) => ({ amount, currency: 'EUR' })
const catalog = [
  { name: 'Frost Escape Parka', meta: 'Sun yellow · Jackets', price: eur(14900), image: image('frost-escape-parka') },
  { name: 'Winterwonder Parka', meta: 'Jackets', price: eur(18900), image: image('winterwonder-parka') },
  { name: 'Blizzard Defender', meta: 'Aubergine · Jackets', price: eur(12600), image: image('blizzard-defender-aubergine') },
  { name: 'Polar Light Ski Jacket', meta: 'Dusty pink · Jackets', price: eur(7900), image: image('polar-light-ski-jacket') },
  { name: 'Snowflake Ski Goggles', meta: 'Mint · Goggles', price: eur(12700), image: image('snowflake-ski-goggles') },
  { name: 'Mountain View Ski Goggles', meta: 'Goggles', price: eur(9900), image: image('mountain-view-ski-goggles') },
  { name: 'TrailMaster 22', meta: 'Brown · 20L · Backpacks', price: eur(14100), image: image('trailmaster-22') },
]
const categories = ['Parkas', 'Ski jackets', 'Ski goggles', 'Backpacks']
const suggestions = ['parka', 'ski goggles', 'backpack', 'Northridge']

const query = ref('')
const answered = ref('')
const loading = ref(false)
let timer: ReturnType<typeof setTimeout> | undefined
watch(query, (next) => {
  clearTimeout(timer)
  loading.value = !!next.trim()
  timer = setTimeout(() => {
    answered.value = next.trim()
    loading.value = false
  }, 300)
})

const matches = (text: string) => text.toLowerCase().includes(answered.value.toLowerCase())
const products = computed(() => (answered.value ? catalog.filter((product) => matches(product.name) || matches(product.meta)).slice(0, 4) : []))
const found = computed(() => (answered.value ? categories.filter(matches) : []))
const href = (text: string) => `#${encodeURIComponent(text)}`
</script>

<template>
  <Search v-model:query="query" :loading="loading" class="w-md max-w-full">
    <SearchInput placeholder="Search jackets, goggles, packs" />
    <SearchResults>
      <template v-if="!query.trim()">
        <SearchGroup heading="Popular searches">
          <div class="flex flex-wrap gap-2 px-3 pb-3">
            <SearchTag v-for="suggestion in suggestions" :key="suggestion" :query="suggestion" />
          </div>
        </SearchGroup>
      </template>
      <template v-else>
        <SearchQuery />
        <SearchLoading v-if="loading" />
        <template v-else>
          <SearchGroup v-if="products.length" heading="Products">
            <SearchItem v-for="product in products" :key="product.name" :value="product.name" :href="href(product.name)">
              <Image :src="product.image" alt="" class="size-12 shrink-0 object-cover" />
              <span class="flex min-w-0 flex-1 flex-col">
                <SearchHighlight :text="product.name" class="truncate" />
                <span class="truncate text-muted-foreground">{{ product.meta }}</span>
              </span>
              <Price :price="product.price" align="end" />
            </SearchItem>
          </SearchGroup>
          <SearchGroup v-if="found.length" heading="Categories">
            <SearchItem v-for="category in found" :key="category" :value="category" :href="href(category)">
              <SearchHighlight :text="category" />
            </SearchItem>
          </SearchGroup>
          <SearchEmpty v-if="!products.length && !found.length">No results for "{{ answered }}". Try one of these:</SearchEmpty>
          <div v-if="!products.length && !found.length" class="flex flex-wrap gap-2 px-3 pb-3">
            <SearchTag v-for="suggestion in suggestions" :key="suggestion" :query="suggestion" />
          </div>
        </template>
      </template>
    </SearchResults>
  </Search>
</template>
```

The example also uses `image` and `price`, which `add search` leaves out: `npx @frontic/ui add image price`.

## Anatomy

```vue
<Search>
  <SearchInput />
  <SearchResults>
    <SearchGroup>
      <SearchTag />
      <SearchItem>
        <SearchHighlight />
      </SearchItem>
    </SearchGroup>
    <SearchQuery />
    <SearchLoading />
    <SearchEmpty />
    <SearchTag />
  </SearchResults>
</Search>
```

## API reference

### Search

Groups all parts of the search: a field whose panel offers results, suggestions and a "Search for" row.

`data-slot="search"` · `Search.vue`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `loading` | `boolean` | `false` | Shows the loading rows and keeps the "Search for" row usable. |
| `openOnFocus` | `boolean` | `true` | Opens the panel when the field gets focus, so suggestions show before typing. |
| `loop` | `boolean` | `true` | Wraps the arrow keys from the last row to the first. |
| `query` | `string` | `''` | What the shopper typed; the shop runs its search on every change. |
| `open` | `boolean` | `false` | Whether the panel is open. |

| Event | Payload | Description |
| --- | --- | --- |
| `update:open` | `value: boolean` | Fires with the new value when `open` changes; `v-model:open` binds it. |
| `select` | `value: unknown` | The shopper opened a result or card; a link navigates by itself. |
| `submit` | `query: string` | The shopper asked for every result for the query: Enter on the "Search for" row. |
| `update:query` | `value: string` | Fires with the new value when `query` changes; `v-model:query` binds it. |

| Slot | Slot props | Description |
| --- | --- | --- |
| `default` | `{ query: string; open: boolean; }` | Holds the input and the results panel; gets the query and whether the panel is open. |

### SearchEmpty

A line of text in the panel, such as "No results for …"; the shop decides when it shows. Renders a `<div>`.

`data-slot="search-empty"` · `SearchEmpty.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. |

### SearchGroup

A headed section of the panel: products, categories, brands, or suggestions.

`data-slot="search-group"` · `SearchGroup.vue`

Styling: `search-group-heading`, which extends [`menu-label`](https://ui.frontic.com/DESIGN.md#menu-label). 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. |
| `heading` | `string` |  | Shown above the group's rows and names the group for screen readers. |

### SearchHighlight

Text with every match of the search terms in bold; the terms default to the typed query. Renders a `<span>`.

`data-slot="search-highlight"` · `SearchHighlight.vue`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |
| `text` (required) | `string` |  | The text to mark up, such as a product name. |
| `terms` | `string \| string[]` |  | What counts as a match: one term or several, e.g. a corrected spelling next to the typed one. |

### SearchInput

The search field: a search icon, the input, and a button that clears it.

`data-slot="search-input"` · `SearchInput.vue`

Styling: `search-input`, which extends [`control-box`](https://ui.frontic.com/DESIGN.md#control-box). 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` | `'Search'` | Names the field for screen readers when no label is shown. |
| `clearLabel` | `string` | `'Clear search'` | The clear button's label for screen readers. |

### SearchItem

One row of the panel that opens something: a link, so middle-click and open-in-new-tab work.

`data-slot="search-item"` · `SearchItem.vue`

Styling: `search-item`, which extends [`option-item`](https://ui.frontic.com/DESIGN.md#option-item). 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. |
| `value` (required) | `AcceptableValue`: `null \| string \| number \| bigint \| Record<string, any>` |  | Identifies the row; Search emits it with `select`. |
| `href` | `string` |  | Where the row leads; without one it renders a div and only emits `select`. |
| `disabled` | `boolean` | `false` | Greys the row out; the arrow keys skip it. |

### SearchLoading

Skeleton rows and "Searching…" while the shop's search answers; shows only while Search is loading. Renders a `<div>`.

`data-slot="search-loading"` · `SearchLoading.vue`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |
| `rows` | `number` | `3` | How many skeleton rows stand in for results. |
| `label` | `string` | `'Searching…'` | Read out and shown under the rows. |

### SearchOverlay

Shows the search in an overlay: near the top on wide screens, full screen on phones with a Cancel button.

`data-slot="search-overlay"` · `SearchOverlay.vue`

Styling: `search-overlay`, which extends [`overlay-modal`](https://ui.frontic.com/DESIGN.md#overlay-modal); `search-overlay-backdrop`, which extends [`overlay-backdrop`](https://ui.frontic.com/DESIGN.md#overlay-backdrop). 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. |
| `title` | `string` | `'Search'` | Names the overlay for screen readers. |
| `cancelLabel` | `string` | `'Cancel'` | The phone's Cancel button, which closes the overlay. |
| `open` | `boolean` | `false` | Whether the overlay is open; focus returns to what opened it when it closes. |

| Event | Payload | Description |
| --- | --- | --- |
| `update:open` | `value: boolean` | Fires with the new value when `open` changes; `v-model:open` binds it. |

| Data attribute | Values | Description |
| --- | --- | --- |
| `data-dismissable-layer` |  | Marks a layer that closes on Escape or a click outside it. |
| `data-state` | `'open' \| 'closed'` | The state the part is in, one value per state. |

### SearchQuery

The first row while the shopper types: "Search for …", which submits the query to the results page.

`data-slot="search-query"` · `SearchQuery.vue`

Styling: `search-query`, which extends [`option-item`](https://ui.frontic.com/DESIGN.md#option-item). 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` | `'Search for'` | Shown before the quoted query. |

| Slot | Slot props | Description |
| --- | --- | --- |
| `default` | `{ query: string; }` | Replaces the row's text; gets the trimmed query. |

### SearchResults

The panel of results, suggestions and the "Search for" row, under the field or inside the overlay.

`data-slot="search-results"` · `SearchResults.vue`

Styling: `search-results`, which extends [`overlay`](https://ui.frontic.com/DESIGN.md#overlay). 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. |
| `position` | `'popper' \| 'inline'` | `'popper'` | Popper floats under the field at its width (a class widens it); inline sits in the flow, as inside the overlay. |

| Data attribute | Values | Description |
| --- | --- | --- |
| `data-dismissable-layer` |  | Marks a layer that closes on Escape or a click outside it. |
| `data-empty` |  | Present while no option matches the search. |
| `data-state` | `'open' \| 'closed'` | The state the part is in, one value per state. |

### SearchTag

A suggested search, drawn as a tag; opening it runs that search.

`data-slot="search-tag"` · `SearchTag.vue`

Styling: `search-tag`, which extends [`tag`](https://ui.frontic.com/DESIGN.md#tag). 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. |
| `query` (required) | `string` |  | The search the tag runs. |
| `href` | `string` |  | The results page for the query, which the tag opens as a link; without one, the tag puts the query in the field. |

## Styling

| Part | Recipe |
| --- | --- |
| `input` | [`search-input`](https://ui.frontic.com/DESIGN.md#search-input) |
| `input-clear` | [`search-input-clear`](https://ui.frontic.com/DESIGN.md#search-input-clear) |
| `cancel` | [`search-cancel`](https://ui.frontic.com/DESIGN.md#search-cancel) |
| `results` | [`search-results`](https://ui.frontic.com/DESIGN.md#search-results) |
| `results-viewport` | [`search-results-viewport`](https://ui.frontic.com/DESIGN.md#search-results-viewport) |
| `group` | [`search-group`](https://ui.frontic.com/DESIGN.md#search-group) |
| `group-heading` | [`search-group-heading`](https://ui.frontic.com/DESIGN.md#search-group-heading) |
| `item` | [`search-item`](https://ui.frontic.com/DESIGN.md#search-item) |
| `tag` | [`search-tag`](https://ui.frontic.com/DESIGN.md#search-tag) |
| `query` | [`search-query`](https://ui.frontic.com/DESIGN.md#search-query) |
| `query-term` | [`search-query-term`](https://ui.frontic.com/DESIGN.md#search-query-term) |
| `empty` | [`search-empty`](https://ui.frontic.com/DESIGN.md#search-empty) |
| `loading` | [`search-loading`](https://ui.frontic.com/DESIGN.md#search-loading) |
| `loading-row` | [`search-loading-row`](https://ui.frontic.com/DESIGN.md#search-loading-row) |
| `loading-label` | [`search-loading-label`](https://ui.frontic.com/DESIGN.md#search-loading-label) |
| `highlight` | [`search-highlight`](https://ui.frontic.com/DESIGN.md#search-highlight) |
| `overlay` | [`search-overlay`](https://ui.frontic.com/DESIGN.md#search-overlay) |
| `overlay-backdrop` | [`search-overlay-backdrop`](https://ui.frontic.com/DESIGN.md#search-overlay-backdrop) |
| `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: hover, focus and disabled.

## Source

`components/ui/search/Search.vue`

```vue
<script lang="ts">
/** Groups all parts of the search: a field whose panel offers results, suggestions and a "Search for" row. */
export default {}
</script>

<script setup lang="ts">
import { ComboboxRoot } from 'reka-ui'
import { inject, nextTick, provide, toRef, useTemplateRef } from 'vue'
import { searchContext, searchOverlay } from './keys'

const props = withDefaults(
  defineProps<{
    /** Shows the loading rows and keeps the "Search for" row usable. */
    loading?: boolean
    /** Opens the panel when the field gets focus, so suggestions show before typing. */
    openOnFocus?: boolean
    /** Wraps the arrow keys from the last row to the first. */
    loop?: boolean
  }>(),
  { loading: false, openOnFocus: true, loop: true },
)
const emits = defineEmits<{
  /** The shopper asked for every result for the query: Enter on the "Search for" row. */
  submit: [query: string]
  /** The shopper opened a result or card; a link navigates by itself. */
  select: [value: unknown]
}>()

/** What the shopper typed; the shop runs its search on every change. */
const query = defineModel<string>('query', { default: '' })
/** Whether the panel is open. */
const open = defineModel<boolean>('open', { default: false })

defineSlots<{
  /** Holds the input and the results panel; gets the query and whether the panel is open. */
  default?: (props: { query: string; open: boolean }) => unknown
}>()

// Inside an overlay the panel is the overlay's body, so it stays open until the overlay closes.
const overlay = inject(searchOverlay, null)

const root = useTemplateRef<{ highlightFirstItem?: () => void }>('root')

provide(searchContext, {
  query,
  loading: toRef(props, 'loading'),
  submit: () => {
    if (!query.value.trim()) return
    open.value = false
    overlay?.close()
    emits('submit', query.value.trim())
  },
  run: (next) => {
    query.value = next
    void nextTick(() => root.value?.highlightFirstItem?.())
  },
  close: () => {
    open.value = false
    overlay?.close()
  },
  select: (value) => {
    open.value = false
    overlay?.close()
    emits('select', value)
  },
})
</script>

<template>
  <ComboboxRoot
    ref="root"
    :open="overlay ? true : open"
    @update:open="(next: boolean) => (open = next)"
    data-slot="search"
    ignore-filter
    :reset-search-term-on-blur="false"
    :reset-search-term-on-select="false"
    :open-on-focus="props.openOnFocus"
    :loop="props.loop"
    highlight-on-hover
    class="relative"
  >
    <slot :query="query" :open="overlay ? true : open" />
  </ComboboxRoot>
</template>
```

`components/ui/search/SearchEmpty.vue`

```vue
<script lang="ts">
/** A line of text in the panel, such as "No results for …"; the shop decides when it shows. */
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'] }>(), { as: 'div' })
</script>

<template>
  <Primitive :as="props.as" :as-child="props.asChild" data-slot="search-empty" role="presentation" :class="['fui-search-empty', props.class]">
    <slot />
  </Primitive>
</template>
```

`components/ui/search/SearchGroup.vue`

```vue
<script lang="ts">
/** A headed section of the panel: products, categories, brands, or suggestions. */
export default {}
</script>

<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { ComboboxGroup, ComboboxLabel } from 'reka-ui'

const props = defineProps<{
  class?: HTMLAttributes['class']
  /** Shown above the group's rows and names the group for screen readers. */
  heading?: string
}>()
</script>

<template>
  <ComboboxGroup data-slot="search-group" :class="['fui-search-group', props.class]">
    <ComboboxLabel v-if="props.heading" class="fui-search-group-heading">{{ props.heading }}</ComboboxLabel>
    <slot />
  </ComboboxGroup>
</template>
```

`components/ui/search/SearchHighlight.vue`

```vue
<script lang="ts">
/** Text with every match of the search terms in bold; the terms default to the typed query. */
export default {}
</script>

<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { computed, inject } from 'vue'
import { searchContext } from './keys'

const props = defineProps<{
  class?: HTMLAttributes['class']
  /** The text to mark up, such as a product name. */
  text: string
  /** What counts as a match: one term or several, e.g. a corrected spelling next to the typed one. */
  terms?: string | string[]
}>()

const search = inject(searchContext, null)

const parts = computed(() => {
  const terms = [props.terms ?? search?.query.value ?? '']
    .flat()
    .map((term) => term.trim())
    .filter(Boolean)
    .sort((a, b) => b.length - a.length)
  if (!terms.length) return [{ text: props.text, match: false }]
  const pattern = new RegExp(`(${terms.map((term) => term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})`, 'gi')
  return props.text
    .split(pattern)
    .filter(Boolean)
    .map((text) => ({ text, match: terms.some((term) => term.toLowerCase() === text.toLowerCase()) }))
})
</script>

<template>
  <span data-slot="search-highlight" :class="props.class">
    <template v-for="(part, i) in parts" :key="i">
      <mark v-if="part.match" class="fui-search-highlight text-current">{{ part.text }}</mark>
      <template v-else>{{ part.text }}</template>
    </template>
  </span>
</template>
```

`components/ui/search/SearchInput.vue`

```vue
<script lang="ts">
/** The search field: a search icon, the input, and a button that clears it. */
export default {}
</script>

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

import type { HTMLAttributes } from 'vue'
import { ComboboxAnchor, ComboboxInput } from 'reka-ui'
import { DialogClose } from 'reka-ui'
import { inject, useTemplateRef } from 'vue'
import { searchContext, searchOverlay } from './keys'

defineOptions({ inheritAttrs: false })

const props = withDefaults(
  defineProps<{
    class?: HTMLAttributes['class']
    /** Names the field for screen readers when no label is shown. */
    label?: string
    /** The clear button's label for screen readers. */
    clearLabel?: string
  }>(),
  { label: 'Search', clearLabel: 'Clear search' },
)

const search = inject(searchContext)!
const overlay = inject(searchOverlay, null)
const input = useTemplateRef<{ $el: HTMLInputElement }>('input')

function clear() {
  search.query.value = ''
  input.value?.$el.focus()
}
</script>

<template>
  <div class="flex items-center gap-3">
    <ComboboxAnchor data-slot="search-input" :class="['fui-search-input', props.class]">
      <SearchIcon data-icon class="shrink-0" />
      <ComboboxInput ref="input" v-model="search.query.value" :aria-label="props.label" type="text" enterkeyhint="search" class="min-w-0 flex-1 bg-transparent outline-hidden" v-bind="$attrs" />
      <button
        v-if="search.query.value"
        type="button"
        data-slot="search-input-clear"
        class="fui-search-input-clear flex shrink-0 items-center justify-center outline-none focus-visible:fui-focus-ring"
        :aria-label="props.clearLabel"
        @click="clear"
      >
        <XIcon class="size-4" />
      </button>
    </ComboboxAnchor>
    <DialogClose v-if="overlay" data-slot="search-cancel" class="fui-search-cancel shrink-0 outline-none focus-visible:fui-focus-ring @2xl:hidden">{{ overlay.cancelLabel.value }}</DialogClose>
  </div>
</template>
```

`components/ui/search/SearchItem.vue`

```vue
<script lang="ts">
/** One row of the panel that opens something: a link, so middle-click and open-in-new-tab work. */
export default {}
</script>

<script setup lang="ts">
import type { AcceptableValue } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { ComboboxItem } from 'reka-ui'
import { inject } from 'vue'
import { searchContext } from './keys'

const props = defineProps<{
  class?: HTMLAttributes['class']
  /** Identifies the row; Search emits it with `select`. */
  value: AcceptableValue
  /** Where the row leads; without one it renders a div and only emits `select`. */
  href?: string
  /** Greys the row out; the arrow keys skip it. */
  disabled?: boolean
}>()

const search = inject(searchContext)!

function onSelect(event: Event) {
  event.preventDefault()
  search.select(props.value)
}
</script>

<template>
  <ComboboxItem
    data-slot="search-item"
    :value="props.value"
    :disabled="props.disabled"
    :as="props.href ? 'a' : 'div'"
    :href="props.href"
    :class="['fui-search-item cursor-default outline-hidden select-none data-disabled:pointer-events-none data-disabled:fui-disabled **:data-icon:shrink-0', props.class]"
    @select="onSelect"
  >
    <slot />
  </ComboboxItem>
</template>
```

`components/ui/search/SearchLoading.vue`

```vue
<script lang="ts">
/** Skeleton rows and "Searching…" while the shop's search answers; shows only while Search is loading. */
export default {}
</script>

<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { inject } from 'vue'
import { Skeleton } from '@library/components/ui/skeleton'
import { searchContext } from './keys'

const props = withDefaults(
  defineProps<{
    class?: HTMLAttributes['class']
    /** How many skeleton rows stand in for results. */
    rows?: number
    /** Read out and shown under the rows. */
    label?: string
  }>(),
  { rows: 3, label: 'Searching…' },
)

const search = inject(searchContext)!
</script>

<template>
  <div v-if="search.loading.value" data-slot="search-loading" role="presentation" :class="['fui-search-loading', props.class]">
    <div v-for="row in props.rows" :key="row" class="fui-search-loading-row flex items-center">
      <Skeleton class="size-12 shrink-0" />
      <div class="flex flex-1 flex-col gap-2">
        <Skeleton class="h-3.5 w-2/3" />
        <Skeleton class="h-3 w-1/3" />
      </div>
    </div>
    <p role="status" class="fui-search-loading-label">{{ props.label }}</p>
  </div>
</template>
```

`components/ui/search/SearchOverlay.vue`

```vue
<script lang="ts">
/** Shows the search in an overlay: near the top on wide screens, full screen on phones with a Cancel button. */
export default {}
</script>

<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
import { provide, toRef } from 'vue'
import { searchOverlay } from './keys'

const props = withDefaults(
  defineProps<{
    class?: HTMLAttributes['class']
    /** Names the overlay for screen readers. */
    title?: string
    /** The phone's Cancel button, which closes the overlay. */
    cancelLabel?: string
  }>(),
  { title: 'Search', cancelLabel: 'Cancel' },
)

/** Whether the overlay is open; focus returns to what opened it when it closes. */
const open = defineModel<boolean>('open', { default: false })

provide(searchOverlay, { cancelLabel: toRef(props, 'cancelLabel'), close: () => (open.value = false) })
</script>

<template>
  <DialogRoot v-model:open="open">
    <DialogPortal>
      <DialogOverlay class="fui-search-overlay-backdrop fixed inset-0 z-50 data-open:animate-in data-closed:animate-out data-open:fade-in-0 data-closed:fade-out-0" />
      <div class="pointer-events-none fixed inset-0 z-50 @container">
        <DialogContent
          data-slot="search-overlay"
          :class="[
            'fui-search-overlay pointer-events-auto fixed inset-0 outline-none data-open:animate-in data-closed:animate-out data-open:fade-in-0 data-closed:fade-out-0 duration-100 @2xl:inset-x-4 @2xl:top-16 @2xl:bottom-auto @2xl:mx-auto @2xl:max-h-160 @2xl:max-w-2xl',
            props.class,
          ]"
        >
          <DialogTitle class="sr-only">{{ props.title }}</DialogTitle>
          <slot />
        </DialogContent>
      </div>
    </DialogPortal>
  </DialogRoot>
</template>
```

`components/ui/search/SearchQuery.vue`

```vue
<script lang="ts">
/** The first row while the shopper types: "Search for …", which submits the query to the results page. */
export default {}
</script>

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

import type { HTMLAttributes } from 'vue'
import { ComboboxItem } from 'reka-ui'
import { inject } from 'vue'
import { searchContext } from './keys'

const props = withDefaults(
  defineProps<{
    class?: HTMLAttributes['class']
    /** Shown before the quoted query. */
    label?: string
  }>(),
  { label: 'Search for' },
)

defineSlots<{
  /** Replaces the row's text; gets the trimmed query. */
  default?: (props: { query: string }) => unknown
}>()

const search = inject(searchContext)!

function onSelect(event: Event) {
  event.preventDefault()
  search.submit()
}
</script>

<template>
  <ComboboxItem
    v-if="search.query.value.trim()"
    data-slot="search-query"
    :value="`search-query:${search.query.value.trim()}`"
    :class="['fui-search-query cursor-default outline-hidden select-none **:data-icon:shrink-0', props.class]"
    @select="onSelect"
  >
    <SearchIcon data-icon />
    <span class="min-w-0 truncate">
      <slot :query="search.query.value.trim()"
        >{{ props.label }} "<b class="fui-search-query-term">{{ search.query.value.trim() }}</b
        >"</slot
      >
    </span>
  </ComboboxItem>
</template>
```

`components/ui/search/SearchResults.vue`

```vue
<script lang="ts">
/** The panel of results, suggestions and the "Search for" row, under the field or inside the overlay. */
export default {}
</script>

<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cva, type VariantProps } from 'class-variance-authority'
import { ComboboxContent, ComboboxViewport } from 'reka-ui'
import { computed, inject } from 'vue'
import { searchOverlay } from './keys'

const results = cva('fui-search-results', {
  variants: { position: { popper: 'fui-search-results-popper', inline: 'fui-search-results-inline' } },
  defaultVariants: { position: 'popper' },
})

const layout = {
  popper:
    'z-50 max-h-(--reka-combobox-content-available-height) w-(--reka-combobox-trigger-width) max-w-(--reka-combobox-content-available-width) origin-(--reka-combobox-content-transform-origin) data-open:animate-in data-closed:animate-out data-open:fade-in-0 data-closed:fade-out-0 duration-100',
  inline: 'min-h-0 flex-1',
}

const props = withDefaults(
  defineProps<{
    class?: HTMLAttributes['class']
    /** Popper floats under the field at its width (a class widens it); inline sits in the flow, as inside the overlay. */
    position?: VariantProps<typeof results>['position']
  }>(),
  { position: 'popper' },
)

const overlay = inject(searchOverlay, null)

// Esc closes the overlay at once, not first the panel inside it.
function onEscape(event: KeyboardEvent) {
  if (!overlay) return
  event.preventDefault()
  overlay.close()
}

const layoutClass = computed(() => layout[props.position ?? 'popper'])
</script>

<template>
  <ComboboxContent
    data-slot="search-results"
    @escape-key-down="onEscape"
    :position="props.position === 'inline' ? 'inline' : 'popper'"
    align="start"
    :side-offset="4"
    :collision-padding="16"
    :class="['overflow-hidden', layoutClass, results({ position: props.position, class: props.class })]"
  >
    <ComboboxViewport class="fui-search-results-viewport overflow-y-auto overscroll-contain">
      <slot />
    </ComboboxViewport>
  </ComboboxContent>
</template>
```

`components/ui/search/SearchTag.vue`

```vue
<script lang="ts">
/** A suggested search, drawn as a tag; opening it runs that search. */
export default {}
</script>

<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { ComboboxItem } from 'reka-ui'
import { inject } from 'vue'
import { searchContext } from './keys'

const props = defineProps<{
  class?: HTMLAttributes['class']
  /** The search the tag runs. */
  query: string
  /** The results page for the query, which the tag opens as a link; without one, the tag puts the query in the field. */
  href?: string
}>()

const search = inject(searchContext)!

function onSelect(event: Event) {
  event.preventDefault()
  if (props.href) search.close()
  else search.run(props.query)
}
</script>

<template>
  <ComboboxItem
    data-slot="search-tag"
    :value="`search-tag:${props.query}`"
    :text-value="props.query"
    :as="props.href ? 'a' : 'div'"
    :href="props.href"
    :class="['fui-search-tag cursor-default whitespace-nowrap outline-hidden select-none', props.class]"
    @select="onSelect"
  >
    <slot>{{ props.query }}</slot>
  </ComboboxItem>
</template>
```

`components/ui/search/keys.ts`

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

export interface SearchContext {
  query: Ref<string>
  loading: Ref<boolean>
  submit: () => void
  /** Closes the panel, and the overlay around it, while a link navigates. */
  close: () => void
  /** Puts a query in the field and highlights its "Search for" row, as typing does. */
  run: (query: string) => void
  select: (value: unknown) => void
}

export const searchContext: InjectionKey<SearchContext> = Symbol('search')

/** Set by SearchOverlay, so SearchInput shows its Cancel button. */
export const searchOverlay: InjectionKey<{ cancelLabel: Ref<string>; close: () => void }> = Symbol('search-overlay')
```

## API of the items the example uses

### Image

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

#### Image

Shows an image with a loading state and a fallback if it fails to load. Renders a `<span>`.

`data-slot="image"` · `Image.vue`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |
| `src` | `string` |  | URL of the image to show. |
| `alt` | `string` |  | Text alternative read by screen readers; leave empty for a purely decorative image. |
| `loadingEffect` | `LoadingEffect`: `'skeleton' \| 'blur' \| 'none'` | `'skeleton'` | What shows until the image has loaded: a pulsing placeholder, the `placeholderSrc` preview blurred, or nothing. Blur without a `placeholderSrc` shows the pulsing placeholder. |
| `fallbackSrc` | `string` |  | URL tried when `src` fails to load; the fallback slot shows when both fail. |
| `placeholderSrc` | `string` |  | URL of a tiny version of the image, e.g. 24px wide, shown blurred while `src` loads when `loadingEffect` is `blur`. |
| `priority` | `boolean` | `false` | Loads the image first, eagerly and at high fetch priority, instead of when it nears the viewport. Set it on the one image that is the page's largest paint, such as a hero or the first gallery image. |

| Slot | Slot props | Description |
| --- | --- | --- |
| `fallback` |  | Replaces the placeholder shown when the image fails to load and no fallback URL loads either. |

#### ImageFallback

Shows a placeholder in place of an image that failed to load. Renders a `<span>`.

`data-slot="image-fallback"` · `ImageFallback.vue`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |

### Price

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

#### Price

Shows a price, with the original price and discount percentage when reduced. Renders a `<span>`.

`data-slot="price"` · `Price.vue`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |
| `size` | `PriceSize`: `'md' \| 'sm' \| 'lg'` | `'md'` | The text size of the price. |
| `align` | `PriceAlign`: `'start' \| 'end'` | `'start'` | Which side the badge, the prices and the hint line up on. |
| `price` (required) | `PriceType` |  | The price to show: `amount` and `ref` in minor units such as cents, `currency` as an ISO 4217 code. |
| `locale` | `string` |  | The locale that formats the price, e.g. `de-DE`; the ConfigProvider's when left out. |
| `showRef` | `boolean` | `true` | Shows the reference price struck through when it is higher than the amount. |
| `showDiscount` | `boolean` | `true` | Shows the saving as a percentage next to a discounted price. |
| `showHint` | `boolean` | `true` | Shows the hint below the price when the hint slot holds one. |
| `refLabel` | `string` | `'Was'` | Read out before the original price of a reduced product; hidden on screen. |
| `amountLabel` | `string` | `'Now'` | Read out before the current price of a reduced product; hidden on screen. |

| Slot | Slot props | Description |
| --- | --- | --- |
| `hint` |  | A note below the price, such as tax and shipping. |

#### PriceAmount

Shows the current price of the product. Renders a `<span>`.

`data-slot="price-amount"` · `PriceAmount.vue`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |

#### PriceRef

Shows the original price of a discounted product. Renders a `<span>`.

`data-slot="price-ref"` · `PriceRef.vue`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |

#### PriceDiscount

Shows the percentage saved against the original price. Renders a `<span>`.

`data-slot="price-discount"` · `PriceDiscount.vue`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |

| Slot | Slot props | Description |
| --- | --- | --- |
| `default` | `{ percent: number; }` | Replaces the discount badge; gets the percentage saved. |

#### PriceHint

Holds a note below the price, such as tax and shipping, which may link on. Renders a `<span>`.

`data-slot="price-hint"` · `PriceHint.vue`

Styling: `price-hint`, which extends [`link-foreground`](https://ui.frontic.com/DESIGN.md#link-foreground). 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` | `'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. |
