---
name: product-options
title: Product Options
url: https://ui.frontic.com/components/sections/product-options.md
tier: sections
install: npx @frontic/ui add product-options
files:
  - components/sections/product-options/ProductOptions.vue
  - components/sections/product-options/index.ts
  - components/sections/product-options/types.ts
registryDependencies:
  - button
  - card
  - field
  - image
  - number-field
  - price
  - rating
  - swatch-group
  - toggle-group
dependencies:
  - "@lucide/vue"
---

# Product Options

Product buy box with the product shot, price and rating, colour swatches, size choice with sold-out sizes, a quantity stepper, and add-to-cart and wishlist actions.

## Install

```sh
npx @frontic/ui add product-options
```

Built from: [Button](https://ui.frontic.com/components/ui/button.md), [Card](https://ui.frontic.com/components/ui/card.md), [Field](https://ui.frontic.com/components/ui/field.md), [Image](https://ui.frontic.com/components/ui/image.md), [Number Field](https://ui.frontic.com/components/ui/number-field.md), [Price](https://ui.frontic.com/components/ui/price.md), [Rating](https://ui.frontic.com/components/ui/rating.md), [Swatch Group](https://ui.frontic.com/components/ui/swatch-group.md), [Toggle Group](https://ui.frontic.com/components/ui/toggle-group.md)

npm packages: `@lucide/vue`

## Examples

```vue
<script setup lang="ts">
import { ref } from 'vue'
import { ProductOptions, type ProductColour, type ProductSize } from '@/components/sections/product-options'

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

const colours: ProductColour[] = [
  { value: 'aubergine', label: 'Aubergine', hex: '#3d3347', image: image('blizzard-defender-aubergine'), alt: 'Blizzard Defender in Aubergine' },
  { value: 'dusty-rose', label: 'Dusty rose', hex: '#b98a87', image: image('blizzard-defender-dusty-rose'), alt: 'Blizzard Defender in Dusty rose' },
  { value: 'salmon', label: 'Salmon', hex: '#dc967b', image: image('blizzard-defender-salmon'), alt: 'Blizzard Defender in Salmon' },
]

const sizes: ProductSize[] = [{ value: 'S' }, { value: 'M' }, { value: 'L' }, { value: 'XL' }, { value: 'XXL', disabled: true }]

const colour = ref('aubergine')
const size = ref('M')
const quantity = ref(1)
</script>

<template>
  <div class="mx-auto w-full max-w-md">
    <ProductOptions
      v-model:colour="colour"
      v-model:size="size"
      v-model:quantity="quantity"
      title="Blizzard Defender"
      description="Men · Jackets"
      :price="{ amount: 12600, currency: 'EUR' }"
      :rating="4.6"
      :rating-count="212"
      :colours="colours"
      :sizes="sizes"
      :max-quantity="5"
    />
  </div>
</template>
```

## API reference

### ProductOptions

Lets the shopper choose colour, size and quantity, then add the product to the cart.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `title` (required) | `string` |  | The product name, shown as the card's title. |
| `description` | `string` |  | Shown under the title, such as a short product summary. |
| `price` (required) | `PriceType` |  | The product price, shown beside the image. |
| `image` | `string` |  | The product shot; the shown colour's own image replaces it when it has one. |
| `imageAlt` | `string` |  | Alt text of the product shot; the title when left out. |
| `rating` | `number` |  | The average rating out of 5; no stars show when left out. |
| `ratingCount` | `number` |  | How many reviews the rating is based on, shown beside the stars. |
| `colours` | `ProductColour[]` | `[]` | The colours to choose from as swatches; the colour picker hides when empty. |
| `sizes` | `ProductSize[]` | `[]` | The sizes to choose from; the size picker hides when empty. |
| `maxQuantity` | `number` | `10` | The most the quantity stepper allows. |
| `colourLabel` | `string` | `'Colour'` | Shown above the colour swatches. |
| `sizeLabel` | `string` | `'Size'` | Shown above the size buttons, and read before each size by screen readers. |
| `quantityLabel` | `string` | `'Quantity'` | Shown above the quantity stepper. |
| `addToCartLabel` | `string` | `'Add to cart'` | Shown on the button that adds the selection to the cart. |
| `wishlistLabel` | `string` | `'Save to wishlist'` | Screen-reader label of the heart button that saves the product to the wishlist. |
| `surface` | `'raised' \| 'bare'` | `'raised'` | Bare drops the edge, shadow and inset, for a section placed inside another Card. |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |
| `colour` | `string` |  | The selected colour, by value; the first colour shows when none is. |
| `size` | `string` |  | The selected size, by value. |
| `quantity` | `number` | `1` | How many the shopper adds to the cart. |

| Event | Payload | Description |
| --- | --- | --- |
| `wishlist` |  | Fires when the shopper clicks the wishlist button. |
| `update:colour` | `value: string \| undefined` | Fires with the new value when `colour` changes; `v-model:colour` binds it. |
| `update:size` | `value: string \| undefined` | Fires with the new value when `size` changes; `v-model:size` binds it. |
| `update:quantity` | `value: number` | Fires with the new value when `quantity` changes; `v-model:quantity` binds it. |
| `addToCart` | `selection: ProductSelection` | Fires with the chosen colour, size and quantity when the shopper adds the product to the cart. |

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

## Exports

```ts
import { type ProductColour, type ProductSelection, type ProductSize } from '@/components/sections/product-options'
```

- `ProductColour`: below
- `ProductSelection`: below
- `ProductSize`: below

```ts
interface ProductColour {
  value: string
  label: string
  hex: string
  /** The product shot in this colour; the main image shows when it is missing. */
  image?: string
  alt?: string
}

interface ProductSelection {
  colour?: string
  size?: string
  quantity: number
}

interface ProductSize {
  value: string
  label?: string
  /** Sold out: shown but not selectable. */
  disabled?: boolean
}
```

## Styling

| Part | Recipe |
| --- | --- |
| `image` | [`product-options-image`](https://ui.frontic.com/DESIGN.md#product-options-image) |

## Source

`components/sections/product-options/ProductOptions.vue`

```vue
<script lang="ts">
/** Lets the shopper choose colour, size and quantity, then add the product to the cart. */
export default {}
</script>

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

import type { HTMLAttributes } from 'vue'
import type { PriceType } from '@library/components/ui/price'
import type { ProductColour, ProductSelection, ProductSize } from './types'
import { computed } from 'vue'
import { Button } from '@library/components/ui/button'
import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@library/components/ui/card'
import { Field, FieldGroup, FieldLabel } from '@library/components/ui/field'
import { Image } from '@library/components/ui/image'
import { NumberField, NumberFieldContent, NumberFieldDecrement, NumberFieldIncrement, NumberFieldInput } from '@library/components/ui/number-field'
import { Price } from '@library/components/ui/price'
import { Rating } from '@library/components/ui/rating'
import { SwatchGroup, SwatchGroupItem } from '@library/components/ui/swatch-group'
import { ToggleGroup, ToggleGroupItem } from '@library/components/ui/toggle-group'

const props = withDefaults(
  defineProps<{
    /** The product name, shown as the card's title. */
    title: string
    /** Shown under the title, such as a short product summary. */
    description?: string
    /** The product price, shown beside the image. */
    price: PriceType
    /** The product shot; the shown colour's own image replaces it when it has one. */
    image?: string
    /** Alt text of the product shot; the title when left out. */
    imageAlt?: string
    /** The average rating out of 5; no stars show when left out. */
    rating?: number
    /** How many reviews the rating is based on, shown beside the stars. */
    ratingCount?: number
    /** The colours to choose from as swatches; the colour picker hides when empty. */
    colours?: ProductColour[]
    /** The sizes to choose from; the size picker hides when empty. */
    sizes?: ProductSize[]
    /** The most the quantity stepper allows. */
    maxQuantity?: number
    /** Shown above the colour swatches. */
    colourLabel?: string
    /** Shown above the size buttons, and read before each size by screen readers. */
    sizeLabel?: string
    /** Shown above the quantity stepper. */
    quantityLabel?: string
    /** Shown on the button that adds the selection to the cart. */
    addToCartLabel?: string
    /** Screen-reader label of the heart button that saves the product to the wishlist. */
    wishlistLabel?: string
    /** Bare drops the edge, shadow and inset, for a section placed inside another Card. */
    surface?: 'raised' | 'bare'
    class?: HTMLAttributes['class']
  }>(),
  {
    surface: 'raised',
    colours: () => [],
    sizes: () => [],
    maxQuantity: 10,
    colourLabel: 'Colour',
    sizeLabel: 'Size',
    quantityLabel: 'Quantity',
    addToCartLabel: 'Add to cart',
    wishlistLabel: 'Save to wishlist',
  },
)
const emit = defineEmits<{
  /** Fires with the chosen colour, size and quantity when the shopper adds the product to the cart. */
  addToCart: [selection: ProductSelection]
  /** Fires when the shopper clicks the wishlist button. */
  wishlist: []
}>()

/** The selected colour, by value; the first colour shows when none is. */
const colour = defineModel<string>('colour')
/** The selected size, by value. */
const size = defineModel<string>('size')
/** How many the shopper adds to the cart. */
const quantity = defineModel<number>('quantity', { default: 1 })

const shown = computed(() => props.colours.find((c) => c.value === colour.value) ?? props.colours[0])
const image = computed(() => shown.value?.image ?? props.image)
const alt = computed(() => shown.value?.alt ?? props.imageAlt ?? props.title)

function addToCart() {
  emit('addToCart', { colour: colour.value ?? shown.value?.value, size: size.value, quantity: quantity.value })
}
</script>

<template>
  <Card :surface="surface" :class="['@container', props.class]">
    <CardHeader>
      <CardTitle>{{ title }}</CardTitle>
      <CardDescription v-if="description">{{ description }}</CardDescription>
      <CardAction>
        <Button variant="ghost" color="neutral" size="icon-sm" :aria-label="wishlistLabel" @click="emit('wishlist')">
          <HeartIcon data-icon />
        </Button>
      </CardAction>
    </CardHeader>
    <CardContent>
      <FieldGroup>
        <div class="flex items-center gap-4">
          <div v-if="image" class="size-16 shrink-0 @sm:size-24">
            <div data-slot="product-options-image" class="fui-product-options-image size-full overflow-hidden">
              <Image :src="image" :alt="alt" class="size-full" />
            </div>
          </div>
          <div class="flex min-w-0 flex-col gap-2">
            <Price :price="price" size="lg" />
            <Rating v-if="rating !== undefined" :rating="rating" :count="ratingCount" show-value class="flex-wrap" />
          </div>
        </div>
        <Field v-if="colours.length">
          <FieldLabel :native-label="false">{{ colourLabel }}</FieldLabel>
          <SwatchGroup :model-value="shown?.value" type="single" @update:model-value="colour = ($event as string) || colour">
            <SwatchGroupItem v-for="c in colours" :key="c.value" :value="c.value" :hex="c.hex" :label="c.label" />
          </SwatchGroup>
        </Field>
        <Field v-if="sizes.length">
          <FieldLabel>{{ sizeLabel }}</FieldLabel>
          <ToggleGroup v-model="size" type="single" variant="outline" class="w-full @sm:w-auto">
            <ToggleGroupItem v-for="s in sizes" :key="s.value" :value="s.value" :aria-label="`${sizeLabel} ${s.label ?? s.value}`" :disabled="s.disabled" class="min-w-0 flex-1 @sm:flex-none">
              {{ s.label ?? s.value }}
            </ToggleGroupItem>
          </ToggleGroup>
        </Field>
        <Field>
          <FieldLabel>{{ quantityLabel }}</FieldLabel>
          <NumberField v-model="quantity" :min="1" :max="maxQuantity">
            <NumberFieldContent>
              <NumberFieldDecrement />
              <NumberFieldInput />
              <NumberFieldIncrement />
            </NumberFieldContent>
          </NumberField>
        </Field>
      </FieldGroup>
    </CardContent>
    <CardFooter>
      <Button color="buy" class="w-full" @click="addToCart">{{ addToCartLabel }}</Button>
    </CardFooter>
  </Card>
</template>
```

`components/sections/product-options/types.ts`

```ts
export interface ProductColour {
  value: string
  label: string
  hex: string
  /** The product shot in this colour; the main image shows when it is missing. */
  image?: string
  alt?: string
}

export interface ProductSize {
  value: string
  label?: string
  /** Sold out: shown but not selectable. */
  disabled?: boolean
}

export interface ProductSelection {
  colour?: string
  size?: string
  quantity: number
}
```
