---
name: swatch
title: Swatch
url: https://ui.frontic.com/components/ui/swatch.md
tier: ui
install: npx @frontic/ui add swatch
files:
  - components/ui/swatch/Swatch.vue
  - components/ui/swatch/SwatchFallback.vue
  - components/ui/swatch/SwatchImage.vue
  - components/ui/swatch/SwatchLabel.vue
  - components/ui/swatch/SwatchRoot.vue
  - components/ui/swatch/index.ts
dependencies:
  - "@vueuse/core"
  - class-variance-authority
  - reka-ui
---

# Swatch

A colour or picture of one product option, such as a jacket's colour, that the shopper presses to choose it; can be marked unavailable.

## Install

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

Used by: [Filter](https://ui.frontic.com/components/ui/filter.md), [Swatch Group](https://ui.frontic.com/components/ui/swatch-group.md)

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

## Examples

```vue
<script setup lang="ts">
import { Swatch } from '@/components/ui/swatch'
</script>

<template>
  <Swatch hex="#3d3347" label="Aubergine" />
</template>
```

## Anatomy

```vue
<Swatch>
  <SwatchRoot>
    <SwatchFallback />
  </SwatchRoot>
  <SwatchLabel />
</Swatch>
```

## API reference

### Swatch

Lets the user select a colour or material option, shown as a labelled sample.

`data-slot="swatch"` · `Swatch.vue`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `defaultValue` | `boolean` | `false` | The pressed state of the toggle when it is initially rendered. Use when you do not need to control its open state. |
| `modelValue` | `boolean` | `false` | The controlled pressed state of the toggle. Can be bind as `v-model`. |
| `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`. |
| `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. |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |
| `size` | `SwatchSize`: `'md' \| 'xs' \| 'sm' \| 'lg'` | `'md'` | The size of the swatch. |
| `shape` | `SwatchShape`: `'default' \| 'pill'` | `'default'` | The corner shape: 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. |
| `labelPosition` | `SwatchLabelPosition`: `'bottom' \| 'end'` | `'bottom'` | Where the label sits: below the swatch or after it. |
| `value` | `AcceptableValue`: `null \| string \| number \| bigint \| Record<string, any>` |  | The value the swatch stands for inside a SwatchGroup. |
| `unavailable` | `boolean` | `false` | Strikes the swatch through, e.g. for a sold-out colour; it stays selectable. |
| `unavailableLabel` | `string` | `'Unavailable'` | Read to screen readers after the swatch's name when it is unavailable. |

| Event | Payload | Description |
| --- | --- | --- |
| `update:modelValue` | `value: boolean` | Event handler called when the value of the toggle changes. |

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

### SwatchRoot

Groups the image and fallback of a swatch and shares its shape and size.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `hex` | `string` |  | The colour the fallback fills with while no image is loaded. |
| `shape` | `SwatchShape`: `'default' \| 'pill'` | `'default'` | The corner shape the image and fallback take. |
| `size` | `SwatchSize`: `'md' \| 'xs' \| 'sm' \| 'lg'` |  | The size the image and fallback take. |

### SwatchImage

Shows a picture of the option, such as a material or pattern. Renders a `<img>`.

`data-slot="swatch-image"` · `SwatchImage.vue`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `src` (required) | `string` |  | The URL of the image that shows the option. |
| `alt` | `string` |  | Describes the image for screen readers. |
| `referrerPolicy` | `HTMLAttributeReferrerPolicy`: `'' \| 'no-referrer' \| 'no-referrer-when-downgrade' \| 'origin' \| 'origin-when-cross-origin' \| 'same-origin' \| 'strict-origin' \| 'strict-origin-when-cross-origin' \| 'unsafe-url'` |  | The referrer policy used when loading the image. |
| `crossOrigin` | `'' \| 'anonymous' \| 'use-credentials'` |  | The CORS mode used when loading the image. |
| `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` | `'img'` | The element or component this component should render as. Can be overwritten by `asChild`. |

| Event | Payload | Description |
| --- | --- | --- |
| `loadingStatusChange` | `value: ImageLoadingStatus` | Fires with the image's loading status, `idle`, `loading`, `loaded` or `error`, when the image mounts and each time the status changes. |

### SwatchFallback

Shows the option's colour while its image loads or when there is none. Renders a `<span>`.

`data-slot="swatch-fallback"` · `SwatchFallback.vue`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `delayMs` | `number` |  | Waits this many milliseconds before showing the fallback, so a fast image loads without a flash. |
| `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`. |

### SwatchLabel

Names the option a swatch stands for. Renders a `<span>`.

`data-slot="swatch-label"` · `SwatchLabel.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`. |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |

## Exports

```ts
import { type SwatchSize, type SwatchShape, type SwatchLabelPosition } from '@/components/ui/swatch'
```

- `SwatchSize`: `'xs' \| 'sm' \| 'md' \| 'lg'`
- `SwatchShape`: `'default' \| 'pill'`
- `SwatchLabelPosition`: `'bottom' \| 'end'`

## Styling

| Part | Recipe |
| --- | --- |
| `default` | [`swatch`](https://ui.frontic.com/DESIGN.md#swatch) |
| `strike` | [`swatch-strike`](https://ui.frontic.com/DESIGN.md#swatch-strike) |
| `strike-halo` | [`swatch-strike-halo`](https://ui.frontic.com/DESIGN.md#swatch-strike-halo) |
| `fallback` | [`swatch-fallback`](https://ui.frontic.com/DESIGN.md#swatch-fallback) |
| `image` | [`swatch-image`](https://ui.frontic.com/DESIGN.md#swatch-image) |
| `label` | [`swatch-label`](https://ui.frontic.com/DESIGN.md#swatch-label) |
| `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, checked and disabled.

## Source

`components/ui/swatch/Swatch.vue`

```vue
<script lang="ts">
/** Lets the user select a colour or material option, shown as a labelled sample. */
export default {}
</script>

<script setup lang="ts">
import type { AcceptableValue, ToggleEmits, ToggleProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { cva } from 'class-variance-authority'
import { injectToggleGroupRootContext, Toggle, ToggleGroupItem, useForwardPropsEmits } from 'reka-ui'
import { computed } from 'vue'
import type { SwatchLabelPosition, SwatchShape, SwatchSize } from '.'
import SwatchRoot from './SwatchRoot.vue'
import SwatchFallback from './SwatchFallback.vue'
import SwatchLabel from './SwatchLabel.vue'

const swatch = cva('fui-swatch relative overflow-hidden transition focus-visible:fui-focus-ring disabled:pointer-events-none disabled:fui-disabled', {
  variants: {
    size: { xs: 'fui-swatch-xs', sm: 'fui-swatch-sm', md: 'fui-swatch-md', lg: 'fui-swatch-lg' },
    shape: { default: 'fui-swatch-default', pill: 'fui-swatch-pill' },
  },
  defaultVariants: { size: 'md', shape: 'default' },
})

const props = withDefaults(
  defineProps<
    ToggleProps & {
      class?: HTMLAttributes['class']
      /** The size of the swatch. */
      size?: SwatchSize
      /** The corner shape: rounded or a full circle. */
      shape?: SwatchShape
      /** The colour the swatch shows, as a hex code such as `#1f2937`. */
      hex?: string
      /** Shown with the swatch, e.g. the colour's name. */
      label?: string
      /** Classes added to the label. */
      labelClass?: HTMLAttributes['class']
      /** Where the label sits: below the swatch or after it. */
      labelPosition?: SwatchLabelPosition
      /** The value the swatch stands for inside a SwatchGroup. */
      value?: AcceptableValue
      /** Strikes the swatch through, e.g. for a sold-out colour; it stays selectable. */
      unavailable?: boolean
      /** Read to screen readers after the swatch's name when it is unavailable. */
      unavailableLabel?: string
    }
  >(),
  {
    size: 'md',
    shape: 'default',
    labelPosition: 'bottom',
    disabled: false,
    unavailableLabel: 'Unavailable',
  },
)

defineOptions({ inheritAttrs: false })

const emits = defineEmits<ToggleEmits>()

const group = injectToggleGroupRootContext(null)
const delegatedProps = reactiveOmit(props, 'class', 'size', 'shape', 'hex', 'label', 'labelClass', 'labelPosition', 'value', 'unavailable', 'unavailableLabel')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
const control = computed(() =>
  group
    ? { is: ToggleGroupItem, bind: { value: props.value, disabled: props.disabled, as: props.as, asChild: props.asChild, 'data-slot': 'swatch-group-item' } }
    : { is: Toggle, bind: forwarded.value },
)

defineSlots<{
  default?: () => unknown
  /** Replaces the label shown below or beside the swatch. */
  label?: () => unknown
}>()
</script>

<template>
  <label
    :data-disabled="(group && disabled) || undefined"
    :class="['flex items-center data-disabled:pointer-events-none data-disabled:fui-disabled', labelPosition === 'end' ? 'flex-row gap-2' : 'flex-col gap-1']"
  >
    <component :is="control.is" data-slot="swatch" v-bind="{ ...control.bind, ...$attrs }" :data-unavailable="unavailable || undefined" :class="[swatch({ size, shape }), props.class]">
      <SwatchRoot :hex="props.hex" :shape="props.shape" :size="props.size">
        <slot>
          <SwatchFallback v-if="props.hex" />
        </slot>
      </SwatchRoot>
      <svg
        v-if="unavailable"
        data-slot="swatch-strike"
        class="fui-swatch-strike pointer-events-none absolute inset-0"
        width="100%"
        height="100%"
        viewBox="0 0 10 10"
        preserveAspectRatio="none"
        aria-hidden="true"
      >
        <line class="fui-swatch-strike-halo" x1="0" y1="10" x2="10" y2="0" stroke="currentColor" stroke-width="3" vector-effect="non-scaling-stroke" />
        <line x1="0" y1="10" x2="10" y2="0" stroke="currentColor" stroke-width="1" vector-effect="non-scaling-stroke" />
      </svg>
    </component>
    <slot name="label">
      <SwatchLabel v-if="label" :class="labelClass">{{ label }}</SwatchLabel>
    </slot>
    <span v-if="unavailable" class="sr-only">, {{ unavailableLabel }}</span>
  </label>
</template>
```

`components/ui/swatch/SwatchFallback.vue`

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

export interface SwatchFallbackProps extends PrimitiveProps {
  /** Waits this many milliseconds before showing the fallback, so a fast image loads without a flash. */
  delayMs?: number
}

/** Shows the option's colour while its image loads or when there is none. */
export default {}
</script>

<script setup lang="ts">
import { ref, watchEffect } from 'vue'
import { cva } from 'class-variance-authority'
import { Primitive } from 'reka-ui'
import { injectSwatchRootContext } from './SwatchRoot.vue'

const fallback = cva('fui-swatch-fallback absolute inset-px', {
  variants: {
    shape: { default: 'fui-swatch-fallback-default', pill: 'fui-swatch-fallback-pill' },
    size: { xs: 'fui-swatch-fallback-xs', sm: 'fui-swatch-fallback-sm', md: 'fui-swatch-fallback-md', lg: 'fui-swatch-fallback-lg' },
  },
  defaultVariants: { shape: 'default' },
})

const props = withDefaults(defineProps<SwatchFallbackProps>(), { as: 'span' })

const context = injectSwatchRootContext()
const canRender = ref(props.delayMs === undefined)

watchEffect((onCleanup) => {
  if (props.delayMs !== undefined) {
    const timerId = window.setTimeout(() => {
      canRender.value = true
    }, props.delayMs)
    onCleanup(() => window.clearTimeout(timerId))
  }
})
</script>

<template>
  <Primitive
    v-if="canRender && context.imageLoadingStatus.value !== 'loaded'"
    data-slot="swatch-fallback"
    :as-child="asChild"
    :as="as"
    :class="fallback({ shape: context.shape.value, size: context.size.value })"
    :style="context.hex.value ? { backgroundColor: context.hex.value } : undefined"
  >
    <slot />
  </Primitive>
</template>
```

`components/ui/swatch/SwatchImage.vue`

```vue
<script lang="ts">
import type { ImgHTMLAttributes } from 'vue'
import type { PrimitiveProps } from 'reka-ui'
import type { ImageLoadingStatus } from './SwatchRoot.vue'

export interface SwatchImageProps extends PrimitiveProps {
  /** The URL of the image that shows the option. */
  src: string
  /** Describes the image for screen readers. */
  alt?: string
  /** The referrer policy used when loading the image. */
  referrerPolicy?: ImgHTMLAttributes['referrerpolicy']
  /** The CORS mode used when loading the image. */
  crossOrigin?: ImgHTMLAttributes['crossorigin']
}

export type SwatchImageEmits = {
  /** Fires with the image's loading status, `idle`, `loading`, `loaded` or `error`, when the image mounts and each time the status changes. */
  loadingStatusChange: [value: ImageLoadingStatus]
}

/** Shows a picture of the option, such as a material or pattern. */
export default {}
</script>

<script setup lang="ts">
import { useImage } from '@vueuse/core'
import { computed, watch } from 'vue'
import { cva } from 'class-variance-authority'
import { Primitive } from 'reka-ui'
import { injectSwatchRootContext } from './SwatchRoot.vue'

const image = cva('fui-swatch-image object-cover', {
  variants: {
    shape: { default: 'fui-swatch-image-default', pill: 'fui-swatch-image-pill' },
    size: { xs: 'fui-swatch-image-xs', sm: 'fui-swatch-image-sm', md: 'fui-swatch-image-md', lg: 'fui-swatch-image-lg' },
  },
  defaultVariants: { shape: 'default' },
})

const props = withDefaults(defineProps<SwatchImageProps>(), { as: 'img' })
const emits = defineEmits<SwatchImageEmits>()

const context = injectSwatchRootContext()
const { isLoading, isReady, error } = useImage(() => ({ src: props.src, referrerPolicy: props.referrerPolicy as HTMLImageElement['referrerPolicy'], crossorigin: props.crossOrigin }))
const imageLoadingStatus = computed<ImageLoadingStatus>(() => (!props.src || error.value ? 'error' : isReady.value ? 'loaded' : isLoading.value ? 'loading' : 'idle'))

watch(
  imageLoadingStatus,
  (newValue) => {
    emits('loadingStatusChange', newValue)
    if (newValue !== 'idle') context.imageLoadingStatus.value = newValue
  },
  { immediate: true },
)
</script>

<template>
  <Primitive
    v-show="imageLoadingStatus === 'loaded'"
    data-slot="swatch-image"
    :as-child="asChild"
    :as="as"
    :src="src"
    :alt="alt"
    :referrer-policy="referrerPolicy"
    :class="image({ shape: context.shape.value, size: context.size.value })"
  >
    <slot />
  </Primitive>
</template>
```

`components/ui/swatch/SwatchLabel.vue`

```vue
<script lang="ts">
/** Names the option a swatch stands for. */
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: 'span' },
)
</script>

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

`components/ui/swatch/SwatchRoot.vue`

```vue
<script lang="ts">
import type { Ref } from 'vue'
import type { SwatchShape, SwatchSize } from '.'
import { createContext } from 'reka-ui'

/** Where the swatch image is: not started, loading, loaded or failed. */
export type ImageLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error'

export type SwatchRootContext = {
  imageLoadingStatus: Ref<ImageLoadingStatus>
  hex: Ref<string | undefined>
  shape: Ref<SwatchShape>
  size: Ref<SwatchSize | undefined>
}

export const [injectSwatchRootContext, provideSwatchRootContext] = createContext<SwatchRootContext>('SwatchRoot')

/** Groups the image and fallback of a swatch and shares its shape and size. */
export default {}
</script>

<script setup lang="ts">
import { ref, toRef } from 'vue'

const props = withDefaults(
  defineProps<{
    /** The colour the fallback fills with while no image is loaded. */
    hex?: string
    /** The corner shape the image and fallback take. */
    shape?: SwatchShape
    /** The size the image and fallback take. */
    size?: SwatchSize
  }>(),
  { shape: 'default' },
)

provideSwatchRootContext({
  imageLoadingStatus: ref<ImageLoadingStatus>('idle'),
  hex: toRef(props, 'hex'),
  shape: toRef(props, 'shape'),
  size: toRef(props, 'size'),
})
</script>

<template>
  <slot />
</template>
```

`components/ui/swatch/index.ts`

```ts
export { default as Swatch } from './Swatch.vue'
export { default as SwatchRoot } from './SwatchRoot.vue'
export { default as SwatchImage } from './SwatchImage.vue'
export { default as SwatchFallback } from './SwatchFallback.vue'
export { default as SwatchLabel } from './SwatchLabel.vue'

export type SwatchSize = 'xs' | 'sm' | 'md' | 'lg'
export type SwatchShape = 'default' | 'pill'
export type SwatchLabelPosition = 'bottom' | 'end'
```
