---
name: carousel
title: Carousel
url: https://ui.frontic.com/components/ui/carousel.md
tier: ui
install: npx @frontic/ui add carousel
files:
  - components/ui/carousel/Carousel.vue
  - components/ui/carousel/CarouselContent.vue
  - components/ui/carousel/CarouselDots.vue
  - components/ui/carousel/CarouselItem.vue
  - components/ui/carousel/CarouselNext.vue
  - components/ui/carousel/CarouselPrevious.vue
  - components/ui/carousel/CarouselProgress.vue
  - components/ui/carousel/index.ts
  - components/ui/carousel/interface.ts
  - components/ui/carousel/useCarousel.ts
registryDependencies:
  - button
dependencies:
  - "@lucide/vue"
  - "@vueuse/core"
  - class-variance-authority
  - embla-carousel-vue@9.0.0-rc04
  - reka-ui
---

# Carousel

Scrolls slides sideways or up and down, with previous and next buttons, dots that jump to a slide and a bar that shows how far the shopper has scrolled.

## Install

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

Built from: [Button](https://ui.frontic.com/components/ui/button.md)

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

## Examples

```vue
<script setup lang="ts">
import { reactive } from 'vue'
import { Badge } from '@/components/ui/badge'
import { CardDescription, CardTitle } from '@/components/ui/card'
import { Image } from '@/components/ui/image'
import { Price } from '@/components/ui/price'
import { SwatchGroup, SwatchGroupItem } from '@/components/ui/swatch-group'
import { Carousel, CarouselContent, CarouselDots, CarouselItem, CarouselNext, CarouselPrevious } from '@/components/ui/carousel'

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: 'Blizzard Defender',
    price: { amount: 12600, currency: 'EUR' },
    badge: { label: 'Bestseller', variant: 'promo' },
    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: 'Summit Series Winter Parka',
    price: { amount: 13200, currency: 'EUR', ref: 15900 },
    variants: [{ slug: 'summit-series-winter-parka-57dec9f4', colour: 'Charcoal', hex: '#4a4a47', image: 'summit-series-winter-parka' }],
  },
  {
    name: 'Polar Light Ski Jacket',
    price: { amount: 7900, currency: 'EUR' },
    badge: { label: 'New', variant: 'new' },
    variants: [{ slug: 'polarlicht-ski-jacket-476160a6', colour: 'Dusty pink', hex: '#d6aaa6', image: 'polar-light-ski-jacket' }],
  },
  {
    name: 'Glacier Explorer',
    price: { amount: 9400, currency: 'EUR' },
    variants: [{ slug: 'glacier-explorer-53fb49de', colour: 'Slate blue', hex: '#5f7680', image: 'glacier-explorer' }],
  },
  {
    name: 'Arctic Fox Winter Jacket',
    price: { amount: 6100, currency: 'EUR', ref: 7900 },
    variants: [{ slug: 'polarfuchs-winter-jacket-47b6cbf5', colour: 'Light grey', hex: '#d0d0ce', image: 'arctic-fox-winter-jacket' }],
  },
  {
    name: 'Frost Escape Parka',
    price: { amount: 14900, currency: 'EUR' },
    variants: [{ slug: 'frost-escape-parka-3abd4b58', colour: 'Sun yellow', hex: '#e5ad40', image: 'frost-escape-parka' }],
  },
] as const

const picked = reactive<Record<string, string>>(Object.fromEntries(jackets.map((jacket) => [jacket.name, jacket.variants[0].slug])))
const shown = (jacket: (typeof jackets)[number]) => jacket.variants.find((variant) => variant.slug === picked[jacket.name]) ?? jacket.variants[0]
</script>

<template>
  <div class="@container w-4xl max-w-full">
    <div class="mx-auto w-full max-w-sm px-12 @xl:max-w-2xl @4xl:max-w-4xl">
      <Carousel :opts="{ align: 'start' }" class="w-full">
        <CarouselContent>
          <CarouselItem v-for="jacket in jackets" :key="jacket.name" class="@xl:basis-1/2 @4xl:basis-1/3">
            <article class="flex flex-col gap-3">
              <a :href="`${SHOP}/${shown(jacket).slug}`" class="group/link relative block overflow-hidden">
                <Image
                  :src="image(shown(jacket).image)"
                  :alt="`${jacket.name} in ${shown(jacket).colour}`"
                  class="aspect-square w-full transition-transform duration-500 ease-out group-hover/link:scale-105"
                />
                <Badge v-if="'badge' in jacket" :variant="jacket.badge.variant" class="absolute top-3 inset-s-3">{{ jacket.badge.label }}</Badge>
              </a>
              <div class="flex flex-col gap-1">
                <CardTitle>{{ jacket.name }}</CardTitle>
                <Price :price="jacket.price" show-ref />
              </div>
              <SwatchGroup v-if="jacket.variants.length > 1" v-model="picked[jacket.name]" type="single" size="xs" shape="pill" label-class="sr-only">
                <SwatchGroupItem v-for="variant in jacket.variants" :key="variant.slug" :value="variant.slug" :hex="variant.hex" :label="variant.colour" />
              </SwatchGroup>
              <CardDescription v-else>{{ shown(jacket).colour }}</CardDescription>
            </article>
          </CarouselItem>
        </CarouselContent>
        <CarouselPrevious />
        <CarouselNext />
        <CarouselDots class="relative bottom-auto left-auto mt-2 translate-x-0 justify-center" />
      </Carousel>
    </div>
  </div>
</template>
```

The example also uses `badge`, `card`, `image`, `price` and `swatch-group`, which `add carousel` leaves out: `npx @frontic/ui add badge card image price swatch-group`.

## Anatomy

```vue
<Carousel>
  <CarouselContent>
    <CarouselItem />
  </CarouselContent>
  <CarouselPrevious />
  <CarouselNext />
  <CarouselDots />
</Carousel>
```

## API reference

### Carousel

Groups all parts of the carousel, which scrolls through items sideways. Renders a `<div>`.

`data-slot="carousel"` · `Carousel.vue`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `opts` | `Partial<OptionsType>` |  | Embla options, e.g. `loop`, `align` or `slidesToScroll`. |
| `plugins` | `(LoosePluginType & { name: string; options: Partial<{ active: boolean; breakpoints: { [key: string]: Omit<Partial<any>, 'breakpoints'>; }; }>; init: (emblaApi: EmblaCarouselType, OptionsHandler: OptionsHandlerType) => void; destroy: () => void; })[]` |  | Embla plugins, e.g. autoplay. |
| `orientation` | `'vertical' \| 'horizontal'` | `'horizontal'` | The scroll direction; the arrow keys follow it. |
| `roleDescription` | `string` | `'carousel'` | Screen-reader name for the kind of region, announced as e.g. `carousel`. |

| Event | Payload | Description |
| --- | --- | --- |
| `init-api` | `payload: EmblaCarouselType \| undefined` | Fires once on mount with the Embla API, for driving the carousel from outside. |

| Slot | Slot props | Description |
| --- | --- | --- |
| `default` | `{ canScrollNext: boolean; canScrollPrev: boolean; carouselApi: EmblaCarouselType \| undefined; carouselRef: HTMLElement \| undefined; orientation: "vertical" \| "horizontal" \| undefined; scrollNext: () => void; scrollPrev: () => void; }` | Holds the carousel's content and buttons; gets the Embla API and element, the orientation, whether it can scroll either way, and functions that scroll it. |

### CarouselContent

Holds the slides of the carousel in a scrollable row. Renders a `<div>`.

`data-slot="carousel-content"` · `CarouselContent.vue`

### CarouselDots

Shows one dot per slide and lets the user jump to a slide. Renders a `<div>`.

`data-slot="carousel-dots"` · `CarouselDots.vue`

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

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `label` | `((slide: number) => string)`: `(slide: number): string` | ``Go to slide ${slide}`` | Builds each dot's screen-reader label from its 1-based slide number. |

### CarouselItem

Holds the content of one slide in the carousel. Renders a `<div>`.

`data-slot="carousel-item"` · `CarouselItem.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`. |
| `roleDescription` | `string` | `'slide'` | Screen-reader name for each slide, announced as e.g. `slide`. |
| `label` | `((slide: number, total: number) => string)`: `(slide: number, total: number): string` | ``${slide} of ${total}`` | Builds the slide's screen-reader label from its 1-based position and the slide count, e.g. `2 of 6`. |

### CarouselNext

Lets the user scroll the carousel to the next slide.

`data-slot="carousel-next"` · `CarouselNext.vue`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `'solid' \| 'outline' \| 'soft' \| 'ghost' \| 'link'` | `'outline'` | The button style: solid, outline, soft, ghost or link. |
| `color` | `'neutral' \| 'primary' \| 'destructive' \| 'buy' \| 'checkout' \| 'inverse'` | `'neutral'` | The button colour role, e.g. neutral or brand. |
| `size` | `'md' \| 'xs' \| 'sm' \| 'lg' \| 'icon-xs' \| 'icon-sm' \| 'icon-md' \| 'icon-lg'` | `'icon-sm'` | The button's size; the `icon-*` sizes keep it a square icon button. |
| `label` | `string` | `'Next slide'` | Screen-reader text of the button that scrolls to the next slide. |

| Data attribute | Values | Description |
| --- | --- | --- |
| `data-color` | `'neutral' \| 'primary' \| 'destructive' \| 'buy' \| 'checkout' \| 'inverse'` | Carries the `color` prop. |
| `data-size` | `'md' \| 'xs' \| 'sm' \| 'lg' \| 'icon-xs' \| 'icon-sm' \| 'icon-md' \| 'icon-lg'` | Carries the `size` prop. |
| `data-variant` | `'solid' \| 'outline' \| 'soft' \| 'ghost' \| 'link'` | Carries the `variant` prop. |

### CarouselPrevious

Lets the user scroll the carousel to the previous slide.

`data-slot="carousel-previous"` · `CarouselPrevious.vue`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `'solid' \| 'outline' \| 'soft' \| 'ghost' \| 'link'` | `'outline'` | The button style: solid, outline, soft, ghost or link. |
| `color` | `'neutral' \| 'primary' \| 'destructive' \| 'buy' \| 'checkout' \| 'inverse'` | `'neutral'` | The button colour role, e.g. neutral or brand. |
| `size` | `'md' \| 'xs' \| 'sm' \| 'lg' \| 'icon-xs' \| 'icon-sm' \| 'icon-md' \| 'icon-lg'` | `'icon-sm'` | The button's size; the `icon-*` sizes keep it a square icon button. |
| `label` | `string` | `'Previous slide'` | Screen-reader text of the button that scrolls to the previous slide. |

| Data attribute | Values | Description |
| --- | --- | --- |
| `data-color` | `'neutral' \| 'primary' \| 'destructive' \| 'buy' \| 'checkout' \| 'inverse'` | Carries the `color` prop. |
| `data-size` | `'md' \| 'xs' \| 'sm' \| 'lg' \| 'icon-xs' \| 'icon-sm' \| 'icon-md' \| 'icon-lg'` | Carries the `size` prop. |
| `data-variant` | `'solid' \| 'outline' \| 'soft' \| 'ghost' \| 'link'` | Carries the `variant` prop. |

### CarouselProgress

Shows how far the user has scrolled through the carousel. Renders a `<div>`.

`data-slot="carousel-progress"` · `CarouselProgress.vue`

Styling: `carousel-progress`, which extends [`track`](https://ui.frontic.com/DESIGN.md#track); `carousel-progress-indicator`, which extends [`track-fill`](https://ui.frontic.com/DESIGN.md#track-fill). Change them there and every part that extends them follows.

## Exports

```ts
import { type CarouselApi, useCarousel } from '@/components/ui/carousel'
```

- `CarouselApi`: `UnwrapRef<CarouselApi>`
- `useCarousel`

## Styling

| Part | Recipe |
| --- | --- |
| `item` | [`carousel-item`](https://ui.frontic.com/DESIGN.md#carousel-item) |
| `dots` | [`carousel-dots`](https://ui.frontic.com/DESIGN.md#carousel-dots) |
| `content` | [`carousel-content`](https://ui.frontic.com/DESIGN.md#carousel-content) |
| `dot` | [`carousel-dot`](https://ui.frontic.com/DESIGN.md#carousel-dot) |
| `progress` | [`carousel-progress`](https://ui.frontic.com/DESIGN.md#carousel-progress) |
| `progress-indicator` | [`carousel-progress-indicator`](https://ui.frontic.com/DESIGN.md#carousel-progress-indicator) |
| `focus-ring` | [`focus-ring`](https://ui.frontic.com/DESIGN.md#focus-ring) |

States with a look of their own: focus.

## Source

`components/ui/carousel/Carousel.vue`

```vue
<script lang="ts">
/** Groups all parts of the carousel, which scrolls through items sideways. */
export default {}
</script>

<script setup lang="ts">
import type { CarouselEmits, CarouselProps, UnwrapRefCarouselApi, WithClassAsProps } from './interface'
import { useProvideCarousel } from './useCarousel'

const props = withDefaults(
  defineProps<
    CarouselProps &
      WithClassAsProps & {
        /** Screen-reader name for the kind of region, announced as e.g. `carousel`. */
        roleDescription?: string
      }
  >(),
  {
    orientation: 'horizontal',
    roleDescription: 'carousel',
  },
)

const emits = defineEmits<CarouselEmits>()

defineSlots<{
  /** Holds the carousel's content and buttons; gets the Embla API and element, the orientation, whether it can scroll either way, and functions that scroll it. */
  default?: (props: {
    canScrollNext: boolean
    canScrollPrev: boolean
    carouselApi: UnwrapRefCarouselApi
    carouselRef: HTMLElement | undefined
    orientation: CarouselProps['orientation']
    scrollNext: () => void
    scrollPrev: () => void
  }) => unknown
}>()

const { canScrollNext, canScrollPrev, carouselApi, carouselRef, orientation, scrollNext, scrollPrev } = useProvideCarousel(props, emits)

defineExpose({
  canScrollNext,
  canScrollPrev,
  carouselApi,
  carouselRef,
  orientation,
  scrollNext,
  scrollPrev,
})

function onKeyDown(event: KeyboardEvent) {
  const prevKey = props.orientation === 'vertical' ? 'ArrowUp' : 'ArrowLeft'
  const nextKey = props.orientation === 'vertical' ? 'ArrowDown' : 'ArrowRight'

  if (event.key === prevKey) {
    event.preventDefault()
    scrollPrev()

    return
  }

  if (event.key === nextKey) {
    event.preventDefault()
    scrollNext()
  }
}
</script>

<template>
  <div data-slot="carousel" :class="['relative outline-none focus-visible:fui-focus-ring', props.class]" role="region" :aria-roledescription="props.roleDescription" tabindex="0" @keydown="onKeyDown">
    <slot :can-scroll-next :can-scroll-prev :carousel-api :carousel-ref :orientation :scroll-next :scroll-prev />
  </div>
</template>
```

`components/ui/carousel/CarouselContent.vue`

```vue
<script lang="ts">
/** Holds the slides of the carousel in a scrollable row. */
export default {}
</script>

<script setup lang="ts">
import type { WithClassAsProps } from './interface'
import { cva } from 'class-variance-authority'
import { useCarousel } from './useCarousel'

defineOptions({
  inheritAttrs: false,
})

const props = defineProps<WithClassAsProps>()

const { carouselRef, orientation } = useCarousel()

const carouselContent = cva('fui-carousel-content', {
  variants: { orientation: { horizontal: 'fui-carousel-content-horizontal', vertical: 'fui-carousel-content-vertical' } },
  defaultVariants: { orientation: 'horizontal' },
})
</script>

<template>
  <div ref="carouselRef" data-slot="carousel-content" class="overflow-hidden">
    <div :class="[carouselContent({ orientation }), 'touch-pinch-zoom', orientation === 'horizontal' ? 'touch-pan-y -ml-4' : 'touch-pan-x', props.class]" v-bind="$attrs">
      <slot />
    </div>
  </div>
</template>
```

`components/ui/carousel/CarouselDots.vue`

```vue
<script lang="ts">
/** Shows one dot per slide and lets the user jump to a slide. */
export default {}
</script>

<script setup lang="ts">
import type { WithClassAsProps } from './interface'
import { Button } from '@library/components/ui/button'
import { onBeforeUnmount, ref, watch } from 'vue'
import { useCarousel } from './useCarousel'

const props = withDefaults(
  defineProps<
    WithClassAsProps & {
      /** Builds each dot's screen-reader label from its 1-based slide number. */
      label?: (slide: number) => string
    }
  >(),
  {
    label: (slide: number) => `Go to slide ${slide}`,
  },
)

const { carouselApi, scrollTo } = useCarousel()
const snapCount = ref(0)
const activeIndex = ref(0)

let cleanup: (() => void) | undefined

watch(
  () => carouselApi.value,
  (api) => {
    cleanup?.()
    cleanup = undefined
    if (!api) return
    const update = () => {
      snapCount.value = api.snapList().length
      activeIndex.value = api.selectedSnap()
    }
    update()
    api.on('select', update)
    api.on('reinit', update)
    cleanup = () => {
      api.off('select', update)
      api.off('reinit', update)
    }
  },
  { immediate: true },
)

onBeforeUnmount(() => cleanup?.())
</script>

<template>
  <div v-if="snapCount > 0" data-slot="carousel-dots" :class="['fui-carousel-dots absolute bottom-0 left-1/2 -translate-x-1/2', props.class]">
    <Button
      v-for="index in snapCount"
      :key="index"
      variant="ghost"
      color="neutral"
      size="icon-md"
      :aria-label="props.label(index)"
      :aria-current="activeIndex === index - 1"
      @click="scrollTo(index - 1)"
    >
      <span data-slot="carousel-dot" :data-selected="activeIndex === index - 1" class="fui-carousel-dot opacity-90" />
    </Button>
  </div>
</template>
```

`components/ui/carousel/CarouselItem.vue`

```vue
<script lang="ts">
/** Holds the content of one slide in the carousel. */
export default {}
</script>

<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import { cva } from 'class-variance-authority'
import { Primitive } from 'reka-ui'
import type { WithClassAsProps } from './interface'
import { computed, useTemplateRef } from 'vue'
import { useCarousel } from './useCarousel'

const props = withDefaults(
  defineProps<
    PrimitiveProps &
      WithClassAsProps & {
        /** Screen-reader name for each slide, announced as e.g. `slide`. */
        roleDescription?: string
        /** Builds the slide's screen-reader label from its 1-based position and the slide count, e.g. `2 of 6`. */
        label?: (slide: number, total: number) => string
      }
  >(),
  { as: 'div', roleDescription: 'slide', label: (slide: number, total: number) => `${slide} of ${total}` },
)

const { orientation, slides } = useCarousel()

const carouselItem = cva('fui-carousel-item', {
  variants: { orientation: { horizontal: 'fui-carousel-item-horizontal', vertical: 'fui-carousel-item-vertical' } },
  defaultVariants: { orientation: 'horizontal' },
})
const item = useTemplateRef<{ $el: HTMLElement }>('item')
const position = computed(() => (item.value ? slides.value.indexOf(item.value.$el) : -1))
</script>

<template>
  <Primitive
    :as="props.as"
    :as-child="props.asChild"
    ref="item"
    data-slot="carousel-item"
    role="group"
    :aria-roledescription="props.roleDescription"
    :aria-label="position < 0 ? undefined : props.label(position + 1, slides.length)"
    :class="[carouselItem({ orientation }), 'basis-full', orientation === 'horizontal' && 'pl-4', props.class]"
  >
    <slot />
  </Primitive>
</template>
```

`components/ui/carousel/CarouselNext.vue`

```vue
<script lang="ts">
/** Lets the user scroll the carousel to the next slide. */
export default {}
</script>

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

import type { CarouselButtonProps, WithClassAsProps } from './interface'
import { Button } from '@library/components/ui/button'
import { useCarousel } from './useCarousel'

const props = withDefaults(
  defineProps<
    CarouselButtonProps &
      WithClassAsProps & {
        /** Screen-reader text of the button that scrolls to the next slide. */
        label?: string
      }
  >(),
  {
    variant: 'outline',
    color: 'neutral',
    size: 'icon-sm',
    label: 'Next slide',
  },
)

const { orientation, canScrollNext, scrollNext } = useCarousel()
</script>

<template>
  <Button
    data-slot="carousel-next"
    :disabled="!canScrollNext"
    :class="['absolute touch-manipulation', orientation === 'horizontal' ? 'top-1/2 -right-12 -translate-y-1/2' : '-bottom-12 left-1/2 -translate-x-1/2 rotate-90', props.class]"
    :variant="props.variant"
    :color="props.color"
    :size="props.size"
    @click="scrollNext"
  >
    <slot>
      <ChevronRightIcon class="rtl:-scale-x-100" data-icon />
      <span class="sr-only">{{ props.label }}</span>
    </slot>
  </Button>
</template>
```

`components/ui/carousel/CarouselPrevious.vue`

```vue
<script lang="ts">
/** Lets the user scroll the carousel to the previous slide. */
export default {}
</script>

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

import type { CarouselButtonProps, WithClassAsProps } from './interface'
import { Button } from '@library/components/ui/button'
import { useCarousel } from './useCarousel'

const props = withDefaults(
  defineProps<
    CarouselButtonProps &
      WithClassAsProps & {
        /** Screen-reader text of the button that scrolls to the previous slide. */
        label?: string
      }
  >(),
  {
    variant: 'outline',
    color: 'neutral',
    size: 'icon-sm',
    label: 'Previous slide',
  },
)

const { orientation, canScrollPrev, scrollPrev } = useCarousel()
</script>

<template>
  <Button
    data-slot="carousel-previous"
    :disabled="!canScrollPrev"
    :class="['absolute touch-manipulation', orientation === 'horizontal' ? 'top-1/2 -left-12 -translate-y-1/2' : '-top-12 left-1/2 -translate-x-1/2 rotate-90', props.class]"
    :variant="props.variant"
    :color="props.color"
    :size="props.size"
    @click="scrollPrev"
  >
    <slot>
      <ChevronLeftIcon class="rtl:-scale-x-100" data-icon />
      <span class="sr-only">{{ props.label }}</span>
    </slot>
  </Button>
</template>
```

`components/ui/carousel/CarouselProgress.vue`

```vue
<script lang="ts">
/** Shows how far the user has scrolled through the carousel. */
export default {}
</script>

<script setup lang="ts">
import type { WithClassAsProps } from './interface'
import { onBeforeUnmount, ref, watch } from 'vue'
import { useCarousel } from './useCarousel'

const props = defineProps<WithClassAsProps>()

const { carouselApi, orientation } = useCarousel()

const progress = ref(0)

let cleanup: (() => void) | undefined

watch(
  () => carouselApi.value,
  (api) => {
    cleanup?.()
    if (!api) return

    const updateProgress = () => {
      if (!api) return

      // The bar starts filled by the share of the slides in view, so it reaches the end with the last slide.
      const size = orientation === 'vertical' ? 'height' : 'width'
      const content = api.slideNodes().reduce((sum, slide) => sum + slide.getBoundingClientRect()[size], 0)
      const share = content ? Math.min(api.rootNode().getBoundingClientRect()[size] / content, 1) : 1
      const adjustedProgress = share + api.scrollProgress() * (1 - share)

      progress.value = Math.max(0, Math.min(1, adjustedProgress))
    }

    api.on('reinit', updateProgress)
    api.on('scroll', updateProgress)
    api.on('slidefocus', updateProgress)
    api.on('resize', updateProgress)

    cleanup = () => {
      api.off('reinit', updateProgress)
      api.off('scroll', updateProgress)
      api.off('slidefocus', updateProgress)
      api.off('resize', updateProgress)
    }

    updateProgress()
  },
  { immediate: true },
)

onBeforeUnmount(() => cleanup?.())
</script>

<template>
  <div data-slot="carousel-progress" :class="['fui-carousel-progress overflow-hidden', props.class]">
    <div
      data-slot="carousel-progress-indicator"
      class="fui-carousel-progress-indicator h-full w-full transition-transform duration-100 ease-out"
      :style="{
        transform: `translate3d(${(progress - 1) * 100}%,0px,0px)`,
        transformOrigin: '0%',
      }"
    />
  </div>
</template>
```

`components/ui/carousel/interface.ts`

```ts
import type { UseEmblaCarouselType } from 'embla-carousel-vue'
import type { HTMLAttributes, UnwrapRef } from 'vue'
import type { Button } from '@library/components/ui/button'

type CarouselApi = UseEmblaCarouselType[1]
type ReInitParameters = Parameters<UseEmblaCarouselType[2]['reInit']>
type CarouselOptions = ReInitParameters[0]
type CarouselPlugin = ReInitParameters[1]

export type UnwrapRefCarouselApi = UnwrapRef<CarouselApi>

export interface CarouselProps {
  /** Embla options, e.g. `loop`, `align` or `slidesToScroll`. */
  opts?: CarouselOptions
  /** Embla plugins, e.g. autoplay. */
  plugins?: CarouselPlugin
  /** The scroll direction; the arrow keys follow it. */
  orientation?: 'horizontal' | 'vertical'
}

export interface CarouselEmits {
  /** Fires once on mount with the Embla API, for driving the carousel from outside. */
  'init-api': [payload: UnwrapRefCarouselApi]
}

export interface WithClassAsProps {
  class?: HTMLAttributes['class']
}

export interface CarouselButtonProps {
  /** The button style: solid, outline, soft, ghost or link. */
  variant?: InstanceType<typeof Button>['$props']['variant']
  /** The button colour role, e.g. neutral or brand. */
  color?: InstanceType<typeof Button>['$props']['color']
  /** The button's size; the `icon-*` sizes keep it a square icon button. */
  size?: InstanceType<typeof Button>['$props']['size']
}
```

`components/ui/carousel/useCarousel.ts`

```ts
import type { UnwrapRefCarouselApi as CarouselApi, CarouselEmits, CarouselProps } from './interface'
import { createInjectionState } from '@vueuse/core'
import useEmblaCarousel from 'embla-carousel-vue'
import type { EmitFn } from 'vue'
import { ref, shallowRef, watch } from 'vue'

const [useProvideCarousel, useInjectCarousel] = createInjectionState(({ opts, orientation, plugins }: CarouselProps, emits: EmitFn<CarouselEmits>) => {
  const [emblaNode, emblaApi] = useEmblaCarousel(
    {
      ...opts,
      axis: orientation === 'horizontal' ? 'x' : 'y',
    },
    plugins,
  )

  function scrollPrev() {
    emblaApi.value?.goToPrev()
  }
  function scrollNext() {
    emblaApi.value?.goToNext()
  }
  function scrollTo(index: number) {
    emblaApi.value?.goTo(index)
  }

  const canScrollNext = ref(false)
  const canScrollPrev = ref(false)
  const slides = shallowRef<HTMLElement[]>([])

  function onSelect(api: NonNullable<CarouselApi>) {
    canScrollNext.value = api.canGoToNext()
    canScrollPrev.value = api.canGoToPrev()
    slides.value = api.slideNodes()
  }

  // Embla creates its API once the viewport mounts, after this component's own mounted hook.
  watch(
    emblaApi,
    (api) => {
      if (!api) return
      onSelect(api)
      api.on('select', onSelect)
      api.on('reinit', onSelect)
      emits('init-api', api)
    },
    { immediate: true },
  )

  return { carouselRef: emblaNode, carouselApi: emblaApi, canScrollPrev, canScrollNext, scrollPrev, scrollNext, scrollTo, orientation, slides }
})

function useCarousel() {
  const carouselState = useInjectCarousel()

  if (!carouselState) throw new Error('useCarousel must be used within a <Carousel />')

  return carouselState
}

export { useCarousel, useProvideCarousel }
```

## API of the items the example uses

### Badge

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

#### Badge

Marks an item with a short label such as new or sale. Renders a `<span>`.

`data-slot="badge"` · `Badge.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` | `'span'` | The element or component this component should render as. Can be overwritten by `asChild`. |
| `variant` | `'neutral' \| 'info' \| 'success' \| 'warning' \| 'error' \| 'primary' \| 'promo' \| 'discount' \| 'new' \| 'price'` | `'neutral'` | What the badge marks: a state (info, success, warning, error), a commerce label (promo, discount, new, price), the brand colour (primary) or nothing in particular (neutral). Each takes its own colour and form. |
| `size` | `'md' \| 'sm' \| 'lg'` | `'md'` | The badge's padding and text size: sm keeps the smallest text, md and lg take the caption style. |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |

| Data attribute | Values | Description |
| --- | --- | --- |
| `data-variant` | `'neutral' \| 'info' \| 'success' \| 'warning' \| 'error' \| 'primary' \| 'promo' \| 'discount' \| 'new' \| 'price'` | Carries the `variant` prop. |

### 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. |

### 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. |

### Swatch Group

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

#### SwatchGroup

Groups swatches so the user can choose one or several options.

`data-slot="swatch-group"` · `SwatchGroup.vue`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `rovingFocus` | `boolean` | `true` | When `false`, navigating through the items using arrow keys will be disabled. |
| `disabled` | `boolean` | `false` | When `true`, prevents the user from interacting with the toggle group and all its items. |
| `orientation` | `'vertical' \| 'horizontal'` | `'horizontal'` | The orientation of the component, which determines how focus moves: `horizontal` for left/right arrows and `vertical` for up/down arrows. Lays the swatches out in a row, wrapping, or in a column with each label beside its swatch. |
| `dir` | `Direction`: `'ltr' \| 'rtl'` |  | The reading direction of the combobox when applicable.<br><br>If omitted, inherits globally from `ConfigProvider` or assumes LTR (left-to-right) reading mode. |
| `loop` | `boolean` | `true` | When `loop` and `rovingFocus` is `true`, keyboard navigation will loop from last item to first, and vice versa. |
| `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` |  | The element or component this component should render as. Can be overwritten by `asChild`. |
| `name` | `string` |  | The name of the field. Submitted with its owning form as part of a name/value pair. |
| `required` | `boolean` | `false` | When `true`, indicates that the user must set the value before the owning form can be submitted. |
| `type` | `SingleOrMultipleType`: `'single' \| 'multiple'` |  | Determines whether a "single" or "multiple" items can be selected at a time.<br><br>This prop will overwrite the inferred type from `modelValue` and `defaultValue`. |
| `modelValue` | `AcceptableValue \| AcceptableValue[]`: `null \| string \| number \| bigint \| Record<string, any> \| AcceptableValue[]` |  | The controlled value of the active item(s).<br><br>Use this when you need to control the state of the items. Can be binded with `v-model` |
| `defaultValue` | `AcceptableValue \| AcceptableValue[]`: `null \| string \| number \| bigint \| Record<string, any> \| AcceptableValue[]` |  | The default active value of the item(s).<br><br>Use when you do not need to control the state of the item(s). |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |
| `size` | `SwatchSize`: `'md' \| 'xs' \| 'sm' \| 'lg'` |  | The size of every swatch in the group. |
| `shape` | `SwatchShape`: `'default' \| 'pill'` |  | The corner shape of every swatch: rounded or a full circle. |
| `labelClass` | `ClassValue`: `null \| string \| false \| Record<string, any> \| ClassValue[]` |  | Classes added to every swatch's label; an item's own `labelClass` wins. |

| Event | Payload | Description |
| --- | --- | --- |
| `update:modelValue` | `payload: AcceptableValue \| AcceptableValue[]` | Event handler called when the value changes. |

#### SwatchGroupItem

Lets the user select one option within its swatch group.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` (required) | `AcceptableValue`: `null \| string \| number \| bigint \| Record<string, any>` |  | A string value for the toggle group item. All items within a toggle group should use a unique value. |
| `disabled` | `boolean` | `false` | When `true`, prevents the user from interacting with the toggle. |
| `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` |  | 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. |
| `size` | `SwatchSize`: `'md' \| 'xs' \| 'sm' \| 'lg'` |  | The swatch size when the group sets none. |
| `shape` | `SwatchShape`: `'default' \| 'pill'` |  | The corner shape when the group sets none: rounded or a full circle. |
| `hex` | `string` |  | The colour the swatch shows, as a hex code such as `#1f2937`. |
| `label` | `string` |  | Shown with the swatch, e.g. the colour's name. |
| `labelClass` | `ClassValue`: `null \| string \| false \| Record<string, any> \| ClassValue[]` |  | Classes added to the label; the group's `labelClass` applies when left out. |
| `unavailable` | `boolean` | `false` | Strikes the swatch through, e.g. for a sold-out colour; it stays selectable. |
| `unavailableLabel` | `string` |  | Read to screen readers after the swatch's name when it is unavailable. |

| Slot | Slot props | Description |
| --- | --- | --- |
| `label` |  | Replaces the label shown below or beside the swatch. |

| Data attribute | Values | Description |
| --- | --- | --- |
| `data-unavailable` |  | Present when the date cannot be picked. |
