Skip to content

ColorSwatchPicker

A listbox of color swatches for picking one, or several, colors from a palette.

Preview

Selected: hsl(210, 80%, 50%)

Source code
vue
<script setup lang="ts">
import { ref } from "vue";
import {
  ColorSwatchPickerRoot,
  ColorSwatchPickerItem,
  ColorSwatchPickerItemSwatch,
  ColorSwatchPickerItemIndicator,
} from "@urcolor/vue";
import { Check } from "lucide-vue-next";

const colors = [
  "hsl(210, 80%, 50%)",
  "hsl(350, 90%, 60%)",
  "hsl(120, 60%, 45%)",
  "hsl(45, 100%, 55%)",
  "hsl(280, 70%, 55%)",
  "hsl(15, 85%, 55%)",
];

const selected = ref<string>(colors[0]!);
</script>

<template>
  <div class="flex flex-col gap-4">
    <ColorSwatchPickerRoot
      v-model="selected"
      as="div"
      class="flex items-center gap-2"
    >
      <ColorSwatchPickerItem
        v-for="color in colors"
        :key="color"
        :value="color"
        as="div"
        class="
          relative size-10 cursor-pointer rounded-lg outline-none
          data-[highlighted]:outline-2 data-[highlighted]:outline-offset-2
          data-[highlighted]:outline-(--vp-c-brand-1)
        "
      >
        <ColorSwatchPickerItemSwatch
          as="div"
          class="size-full rounded-lg"
        />
        <ColorSwatchPickerItemIndicator
          as="span"
          class="absolute inset-0 grid place-items-center"
        >
          <Check
            class="size-5 text-white drop-shadow-[0_1px_2px_rgba(0,0,0,0.5)]"
          />
        </ColorSwatchPickerItemIndicator>
      </ColorSwatchPickerItem>
    </ColorSwatchPickerRoot>
    <p class="text-sm text-gray-500">
      Selected: <code>{{ selected ?? 'none' }}</code>
    </p>
  </div>
</template>

Different names, same idea

Vue is the odd one out here. React, Svelte and Angular ship this family as ColorSwatchGroup: a plain role="group" whose items are ordinary ColorSwatch toggle buttons, with no item, item-swatch or indicator part, and a value that is always a string[] in both selection modes. Vue's picker is a Reka UI listbox instead. It has all four parts, and its v-model is a single string until you set multiple.

Anatomy

vue
<template>
  <ColorSwatchPickerRoot>
    <ColorSwatchPickerItem value="…">
      <ColorSwatchPickerItemSwatch />
      <ColorSwatchPickerItemIndicator />
    </ColorSwatchPickerItem>
  </ColorSwatchPickerRoot>
</template>

The picker is built on Reka UI's Listbox: the root renders role="listbox" and each item renders role="option".

Examples

Single Selection

Click a swatch to select it. Clicking the selected swatch deselects it again. That is the default selection-behavior="toggle".

Selected: hsl(210, 80%, 50%)

Source code
vue
<script setup lang="ts">
import { ref } from "vue";
import {
  ColorSwatchPickerRoot,
  ColorSwatchPickerItem,
  ColorSwatchPickerItemSwatch,
  ColorSwatchPickerItemIndicator,
} from "@urcolor/vue";
import { Check } from "lucide-vue-next";

const colors = [
  "hsl(210, 80%, 50%)",
  "hsl(350, 90%, 60%)",
  "hsl(120, 60%, 45%)",
  "hsl(45, 100%, 55%)",
  "hsl(280, 70%, 55%)",
  "hsl(15, 85%, 55%)",
];

const selected = ref<string>(colors[0]!);
</script>

<template>
  <div class="flex flex-col gap-4">
    <ColorSwatchPickerRoot
      v-model="selected"
      as="div"
      class="flex items-center gap-2"
    >
      <ColorSwatchPickerItem
        v-for="color in colors"
        :key="color"
        :value="color"
        as="div"
        class="
          relative size-10 cursor-pointer rounded-lg outline-none
          data-[highlighted]:outline-2 data-[highlighted]:outline-offset-2
          data-[highlighted]:outline-(--vp-c-brand-1)
        "
      >
        <ColorSwatchPickerItemSwatch
          as="div"
          class="size-full rounded-lg"
        />
        <ColorSwatchPickerItemIndicator
          as="span"
          class="absolute inset-0 grid place-items-center"
        >
          <Check
            class="size-5 text-white drop-shadow-[0_1px_2px_rgba(0,0,0,0.5)]"
          />
        </ColorSwatchPickerItemIndicator>
      </ColorSwatchPickerItem>
    </ColorSwatchPickerRoot>
    <p class="text-sm text-gray-500">
      Selected: <code>{{ selected ?? 'none' }}</code>
    </p>
  </div>
</template>

Multiple Selection

Set multiple to let any number of swatches be selected at once. The model value becomes an array.

Selected: none

Source code
vue
<script setup lang="ts">
import { ref } from "vue";
import {
  ColorSwatchPickerRoot,
  ColorSwatchPickerItem,
  ColorSwatchPickerItemSwatch,
  ColorSwatchPickerItemIndicator,
} from "@urcolor/vue";
import { Check } from "lucide-vue-next";

const colors = [
  "hsl(210, 80%, 50%)",
  "hsl(350, 90%, 60%)",
  "hsl(120, 60%, 45%)",
  "hsl(45, 100%, 55%)",
  "hsl(280, 70%, 55%)",
  "hsl(15, 85%, 55%)",
];

const selected = ref<string[]>([]);
</script>

<template>
  <div class="flex flex-col gap-4">
    <ColorSwatchPickerRoot
      v-model="selected"
      multiple
      as="div"
      class="flex items-center gap-2"
    >
      <ColorSwatchPickerItem
        v-for="color in colors"
        :key="color"
        :value="color"
        as="div"
        class="
          relative size-10 cursor-pointer rounded-lg outline-none
          data-[highlighted]:outline-2 data-[highlighted]:outline-offset-2
          data-[highlighted]:outline-(--vp-c-brand-1)
        "
      >
        <ColorSwatchPickerItemSwatch
          as="div"
          class="size-full rounded-lg"
        />
        <ColorSwatchPickerItemIndicator
          as="span"
          class="absolute inset-0 grid place-items-center"
        >
          <Check
            class="size-5 text-white drop-shadow-[0_1px_2px_rgba(0,0,0,0.5)]"
          />
        </ColorSwatchPickerItemIndicator>
      </ColorSwatchPickerItem>
    </ColorSwatchPickerRoot>
    <p class="text-sm text-gray-500">
      Selected: <code>{{ selected.length ? selected.join(', ') : 'none' }}</code>
    </p>
  </div>
</template>

Basic usage

The four parts, wired up from scratch.

vue
<script setup lang="ts">
import { ref } from "vue";
import {
  ColorSwatchPickerRoot,
  ColorSwatchPickerItem,
  ColorSwatchPickerItemSwatch,
  ColorSwatchPickerItemIndicator,
} from "@urcolor/vue";

const colors = ["hsl(210, 80%, 50%)", "hsl(350, 90%, 60%)", "hsl(120, 60%, 45%)"];
const selected = ref<string>(colors[0]!);
</script>

<template>
  <ColorSwatchPickerRoot v-model="selected" as="div">
    <ColorSwatchPickerItem
      v-for="color in colors"
      :key="color"
      :value="color"
      as="div"
    >
      <ColorSwatchPickerItemSwatch as="div" />
      <ColorSwatchPickerItemIndicator as="span">✓</ColorSwatchPickerItemIndicator>
    </ColorSwatchPickerItem>
  </ColorSwatchPickerRoot>
</template>

Vertical orientation

orientation decides which arrow keys move the highlight, and is reflected as aria-orientation and data-orientation on the root.

vue
<template>
  <ColorSwatchPickerRoot
    v-model="selected"
    orientation="vertical"
    as="div"
    class="flex flex-col gap-2"
  >
    <!-- items -->
  </ColorSwatchPickerRoot>
</template>

Selection behavior

selection-behavior decides what a click on an already-selected swatch does. The default, "toggle", deselects it; "replace" keeps it selected and replaces the rest of the selection instead.

vue
<template>
  <ColorSwatchPickerRoot v-model="selected" multiple selection-behavior="replace">
    <!-- items -->
  </ColorSwatchPickerRoot>
</template>

API Reference

ColorSwatchPickerRoot

The listbox container. Owns the selection state and arrow-key navigation.

PropTypeDefaultDescription
modelValuestring | string[]The selected color, or colors when multiple is set (v-model).
defaultValuestring | string[]Initially selected color(s) when uncontrolled. Falls back to [] when multiple is set, and to undefined otherwise.
multiplebooleanfalseAllow selecting more than one swatch.
disabledbooleanfalseDisables every swatch in the picker.
orientation'horizontal' | 'vertical''horizontal'Decides which arrow keys navigate the picker.
dir'ltr' | 'rtl'Reading direction. Left unset so it inherits from ConfigProvider.
selectionBehavior'toggle' | 'replace''toggle'toggle lets a second click clear the selection; replace always replaces it.
highlightOnHoverbooleanfalseHovering a swatch highlights it.
namestringName submitted with a parent form, through a visually hidden input.
requiredbooleanfalseMarks the field required in a parent form.
asstring'div'The element or component to render as.
asChildbooleanfalseMerge props onto the single child instead of rendering an element.
EventPayloadDescription
update:modelValuestring | string[] | undefinedEmitted when the selection changes.
highlightCollectionItem | undefinedEmitted when the highlighted (keyboard-focused) item changes.
entryFocusCustomEventEmitted when focus enters the picker.
leaveEventEmitted when the pointer leaves the picker.

The default slot receives the current modelValue as a slot prop: <template #default="{ modelValue }">.

Controlled-ness is fixed at setup

Whether the root owns its own state is decided once, from whether modelValue was supplied at setup, matching useVModel's passive contract. Switching a picker from uncontrolled to controlled after mount will not take effect.

No rovingFocus or loop

Neither prop exists on this component. Highlight movement is handled by the underlying Listbox and is not configurable through the picker's API; arrow keys stop at the first and last swatch rather than wrapping.

ColorSwatchPickerItem

One selectable swatch. Renders role="option" and provides its color to its descendants.

PropTypeDefaultDescription
valuestringrequiredThe color this swatch represents, as a CSS color string. Doubles as the selection key.
disabledbooleanfalsePrevents this swatch from being selected.
asstring'div'The element or component to render as.
asChildbooleanfalseMerge props onto the single child instead of rendering an element.

The item sets --urcolor-swatch-picker-item-color and data-color to the raw value, and its aria-label to the parsed, normalized color string (falling back to the raw value when it cannot be parsed).

It declares no emits of its own, so a @select listener falls through to the underlying ListboxItem. The event is cancellable: calling preventDefault() on it stops the selection.

injectColorSwatchPickerItemContext() is exported too, and returns { color: Ref<string> }, the raw value of the enclosing item. That is how you build your own part that needs the item's color.

ColorSwatchPickerItemSwatch

Renders the item's color, using ColorSwatchRoot internally. It reads the color from the enclosing item, so it takes no modelValue.

PropTypeDefaultDescription
alphabooleanfalseWhen true, reflects the color's alpha channel.
checkerSizenumber16The checkerboard tile size in pixels.
labelstringAutoAccessible name. Falls back to the resolved color string, then "transparent".
asstring'div'The element or component to render as.
asChildbooleanfalseMerge props onto the single child instead of rendering an element.

Its default slot receives { color, alpha }, the resolved sRGB color string and the alpha channel as a number. There is no Checkerboard part in this family: the swatch paints the transparency grid itself, under the color.

ColorSwatchPickerItemIndicator

Renders its children only while the enclosing item is selected, use it for a checkmark or similar affordance.

PropTypeDefaultDescription
asstring'span'The element or component to render as.
asChildbooleanfalseMerge props onto the single child instead of rendering an element.

CSS Variables

VariablePartDescription
--urcolor-swatch-picker-item-colorItemThe item's raw value, exactly as passed.
--urcolor-swatch-colorThe painted color, honouring alpha. transparent when there is no color.
--urcolor-swatch-color-opaqueThe same color forced to alpha 1.
--urcolor-swatch-alphaThe color's alpha channel, 1 when there is no color.
--urcolor-swatch-checkerboardThe transparency grid painted under the color.
--urcolor-swatch-backgroundThe composited background, built from the four above.

All five are always emitted, including when the value is absent or unparseable, so your styling never has to guard for a missing variable. The unprefixed --swatch-color, --swatch-color-opaque, --swatch-alpha and --swatch-checkerboard are still emitted as aliases of their replacements and are deprecated.

The grid itself reads three further properties, and no component writes them, so a rule anywhere above the element wins:

VariableDefaultDescription
--urcolor-checkerboard-darkrgb(230, 230, 230)The darker of the two checks.
--urcolor-checkerboard-lightwhiteThe lighter of the two checks.
--urcolor-checkerboard-size16pxThe tile size. checkerSize writes it inline, which beats a stylesheet.

Data Attributes

AttributeValuesDescription
data-state'checked' | 'unchecked'Whether the item is selected.
data-highlightedPresent when highlightedThe item the keyboard is currently on.
data-disabledPresent when disabledOn the item, and on the root when the whole picker is disabled.
data-orientation'horizontal' | 'vertical'The picker orientation (on the root).
data-colorThe raw valueThe color the item represents.
data-no-colorPresent when invisibleOn the item swatch, when the color is absent, unparseable, or fully transparent.

Accessibility

ColorSwatchPicker uses listbox semantics: a single tab stop, with the highlighted option carrying focus.

ARIA Labels

AttributeDescription
role="listbox"Applied to the root.
role="option"Applied to each item.
aria-multiselectableSet on the root when multiple is enabled.
aria-orientationReflects the orientation prop.
aria-selectedIndicates each item's selection state.
aria-labelSet on each item to the normalized color string, e.g. "rgb(26, 133, 230)".
role="img" + aria-roledescription="color swatch"Applied to the item swatch, which also carries its own aria-label.

No typeahead

Listbox typeahead resolves an option's search text from its textValue or its textContent. Swatch items carry neither. A swatch is a colored box, not text, so every search key resolves against an empty string and highlights the first option. Do not rely on typing a color to jump to it.

Keyboard Navigation

KeyAction
TabMove focus into and out of the picker (one tab stop)
Arrow Left / Arrow RightMove the highlight (horizontal orientation)
Arrow Up / Arrow DownMove the highlight (vertical orientation)
HomeHighlight the first swatch
EndHighlight the last swatch
SpaceSelect the highlighted swatch
EnterSelect the highlighted swatch
Ctrl/Cmd + ASelect every swatch: multiple only

Only the arrow keys for the current orientation are handled; the other pair is ignored. Page Up and Page Down are not bound: the listbox registers navigation for the arrow keys, Home and End only. In rtl, the horizontal arrows are mirrored.