oriUI

useColorPicker

A headless sRGB color picker — a 2D saturation × brightness area, a hue channel, an optional alpha channel, a hex field, and optional preset swatches, assembled from ready-to-bind prop-getters. It owns the working color as an internal HSVA object (so the hue survives a grayscale round-trip when you drag into a corner and back). Underneath is a zero-dependency sRGB engine — a loose parser and a lowercase formatter, hex / rgb / hsv / hsl conversion, readable ink over a swatch by WCAG luminance — and the 2D-area math for pointer and keyboard. That engine is internal: it ships inside this composable, not as exports of its own.

This is the Vue binding; the framework-agnostic core lives in @oriui/headless, and the styled OriColorPicker is built on it. Like useToolbar, this is a compositional helper — deterministic sRGB + 2D-area math exposed as prop-getters and setters — not the OriHeadless adapter contract: a color picker has no swappable engine and no async state to project, so (unlike useDialog's machine-behind-a-swappable-adapter shape) there is nothing to swap. It does reuse the same core roving helpers as the toolbar (rovingIntent / resolveRovingIndex) for the preset listbox — see the roving taxonomy framing.

Import

ts
import { useColorPicker } from '@oriui/headless/vue'

Options

useColorPicker(options) takes its options as a getter — useColorPicker(() => ({ … })), like useToolbar / useMenu — so value / format / disabled stay reactive. The color crosses the boundary as a lowercase string (bind to v-model), with the dual-event convention: onInput streams live on every interaction tick, onChange commits once on release / keyboard settle (one undo entry).

OptionTypeDefaultDescription
valuestring | undefined—The controlled color (the v-model value). Parsed loosely: hex, rgb()/rgba(), hsl()/hsla().
format'hex' | 'rgb' | 'hsl''hex'Output format of the emitted string (ColorFormat). rgb / hsl emit function notation; output is lowercase.
alphabooleanfalseInclude an alpha channel — the emitted string carries it (#rrggbbaa / rgba() / hsla()).
eyedropperbooleanfalseExpose an eyedropper action (the EyeDropper API); eyedropperSupported is false where the browser lacks it.
disabledbooleanfalseBlocks the area pointer drag / keyboard and disables the channel inputs.
presetsstring[]—Preset swatch colors, rendered as a single-select roving listbox.
labelsPartial<ColorPickerLabels>EnglishAccessible names of the parts. The prop-getters apply area, saturation, brightness and presets; read hue, alpha, hex and eyedropper from the returned labels for the parts you render.
onInput(next: string) => void—Required. Live value on every interaction tick — wire to update:modelValue.
onChange(next: string) => void—Required. Committed value on pointer-release / keyboard settle — wire to change (one undo entry).

Returns

Reactive state, prop-getters (spread with v-bind), and imperative setters. HSVA is { h, s, v, a } (h ∈ [0, 360), s / v / a ∈ 0, 1); RGB is { r, g, b } (0–255).

Reactive state

PropertyTypeDescription
hsvaComputedRef<HSVA>The working color — the source of truth during interaction (hue preserved across a grayscale trip).
rgbComputedRef<RGB>The working color as RGB (0–255), derived from hsva.
hexComputedRef<string>The hex the field shows: #rrggbbaa when alpha is on, else #rrggbb.
valueComputedRef<string>The current color in the emitted format — the value a form submits; matches v-model after any emit (an initial value in another format still reports in format). Never empty.
hueComputedRef<number>The current hue, 0–360 (bind to the hue slider).
alphaComputedRef<number>The current alpha, 0–1 (bind to the alpha slider).
swatchColorComputedRef<string>The preview color — carries alpha, so a checkerboard shows through.
opaqueColorComputedRef<string>The opaque current color, for the alpha slider's transparent → color track.
inkComputedRef<'#000000' | '#ffffff'>Readable ink over the current color, per WCAG luminance (readableInk).
hueColorComputedRef<string>The fully-saturated hue color — the area's --ori-color-picker-hue gradient anchor.
eyedropperSupportedRef<boolean>Whether the EyeDropper API exists. Set on mount, so it is false on the server — gate the eyedropper trigger on eyedropperSupported.value.
labelsComputedRef<ColorPickerLabels>Every part's accessible name, with the English defaults filled in.

Prop-getters

PropertyTypeDescription
areaPropsComputedRef<object>The 2D area's bag: role="group", aria-label="Saturation and brightness", aria-disabled, the inline --ori-color-picker-hue style, and the drag onPointerdown + 2D onKeydown.
areaThumbStyleComputedRef<{ left: string; top: string }>Inline position for the area thumb: left = saturation, top = inverted value (1 = top).
getChannelInputProps(channel)(channel: 'saturation' | 'value') => objectProps for one of the two visually-hidden <input type="range"> (the a11y surface): type="range", min/max/step, value (0–100), aria-label, aria-valuetext, onInput.
presetGroupPropsComputedRef<object>The preset listbox bag: role="listbox", aria-label="Preset colors", aria-orientation="horizontal".
getPresetProps(color, index)(color: string, index: number) => objectProps for one preset chip: data-ori-preset, type="button", role="option", aria-label, aria-selected, the roving tabindex, the inline --ori-color style, and onClick / onFocus.
onPresetKeydown(event: KeyboardEvent) => voidThe roving arrow-key handler for the preset listbox — bind to the group's @keydown.

Setters & actions

PropertyTypeDescription
setHue(h)(h: number) => voidSet the hue (wrapped into [0, 360)); emits onInput.
setAlpha(a)(a: number) => voidSet alpha (clamped 0–1); emits onInput.
setSaturationValue(s, v)(s: number, v: number) => voidSet saturation + value (0–1 each); emits onInput. The area's pointer / keyboard math calls this.
setColor(next)(next: string) => booleanParse a full color string (hex entry, preset click) and apply — a commit (emits onInput + onChange). Returns whether it parsed.
setHex(next: string) => booleanAlias of setColor — the name the hex field reads by.
openEyeDropper()() => Promise<void>Open the EyeDropper; on pick, apply the color (keeping the current alpha) and commit. No-op when unsupported / disabled / dismissed.
commit()() => voidCommit the current color (emits onChange) — one undo entry per pointer-release / keyboard settle.

Usage

The picker is assembled from the parts you spread its getters onto — exactly what the styled OriColorPicker wraps. Bind areaProps on the 2D surface with its two visually-hidden range inputs, drive a slider off hue / setHue, feed the hex field through setHex, and render presets as the roving listbox.

vue
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useColorPicker } from '@oriui/headless/vue'
import type { ColorFormat } from '@oriui/headless/vue'

const { format = 'hex', label, presets } = defineProps<{ format?: ColorFormat; label?: string; presets?: string[] }>()

const model = defineModel<string>()
const emit = defineEmits<{ change: [value: string] }>()

// Options are a GETTER, so value / format stay reactive. onInput drives v-model (live),
// onChange commits (one undo entry).
const cp = useColorPicker(() => ({
    value: model.value,
    format,
    presets,
    onInput: (next) => {
        model.value = next
    },
    onChange: (next) => emit('change', next)
}))

// The hex field holds a local draft so a partial entry isn't reformatted mid-type; cp.hex re-seeds it.
const hexDraft = ref(cp.hex.value)
watch(
    () => cp.hex.value,
    (hex) => {
        hexDraft.value = hex
    }
)
</script>

<template>
    <div role="group" :aria-label="label">
        <!-- 2D saturation × value area. The two visually-hidden range inputs are the a11y surface
             (role=slider, focusable, form value); the area's keydown routes the arrows across both axes. -->
        <div v-bind="cp.areaProps.value">
            <span :style="cp.areaThumbStyle.value" aria-hidden="true"></span>
            <input class="sr-only" v-bind="cp.getChannelInputProps('saturation')" />
            <input class="sr-only" v-bind="cp.getChannelInputProps('value')" />
        </div>

        <!-- Preview swatch — role=img named by the current color, ink chosen for contrast. -->
        <span
            role="img"
            :aria-label="cp.hex.value"
            :style="{ background: cp.swatchColor.value, color: cp.ink.value }"
        />

        <!-- Hue slider — a native range in [0, 360); commit on release. -->
        <input
            type="range"
            :min="0"
            :max="360"
            :step="1"
            :value="cp.hue.value"
            :aria-label="cp.labels.value.hue"
            @input="cp.setHue(Number(($event.target as HTMLInputElement).value))"
            @change="cp.commit"
        />

        <!-- Hex field — setHex parses and commits, returning false on a bad hex. -->
        <input
            :value="hexDraft"
            :aria-label="cp.labels.value.hex"
            spellcheck="false"
            @input="hexDraft = ($event.target as HTMLInputElement).value"
            @blur="cp.setHex(hexDraft)"
            @keydown.enter="cp.setHex(hexDraft)"
        />

        <!-- Presets — a single-select roving listbox (one tab stop; arrows move focus). -->
        <div v-if="presets?.length" v-bind="cp.presetGroupProps.value" @keydown="cp.onPresetKeydown">
            <button v-for="(preset, i) in presets" :key="preset" v-bind="cp.getPresetProps(preset, i)"></button>
        </div>
    </div>
</template>

Passing alpha: true adds the alpha channel (bind a second slider to alpha / setAlpha, using opaqueColor for its track), and eyedropper: true enables openEyeDropper (gate its trigger on cp.eyedropperSupported.value) — see how the styled OriColorPicker wires both.

Accessibility

The prop-getters carry the ARIA and keyboard contract; nothing is hand-rolled that a native control already provides.

  • The 2D area is two visually-hidden native <input type="range"> — one per axis (getChannelInputProps('saturation') / ('value')). Each is a real role="slider" with its own aria-label and aria-valuetext ("60%"), focusable, announced, and form-associable. The arrow keys are routed across both axes by areaProps' onKeydown (a single native range spans only one axis); it preventDefaults the navigation keys and bubbles from the focused channel input. getChannelInputProps' own onInput still covers an assistive technology that sets a channel value directly.
  • The preview swatch is role="img", named by the current color; ink picks black or white content for WCAG contrast over it.
  • Presets are a single-select role="listbox" (presetGroupProps) with roving tabindex — one tab stop, arrow keys move real DOM focus (onPresetKeydown, wrapping), aria-selected marks the active color. Each chip is a role="option" button labeled by its color.
  • Name the whole control yourself, with an aria-label on the root role="group" (as the example does); disabled blocks the area drag / keyboard and disables the channel inputs.

Area keyboard (focus is on a channel input; the area routes the key):

KeyAction
ArrowLeft / ArrowRightDecrease / increase saturation by 1% (Shift = ×10).
ArrowDown / ArrowUpDecrease / increase brightness by 1% (Shift = ×10).
PageDown / PageUpDecrease / increase brightness by 10%.
Home / EndJump saturation to 0% / 100%.

See also

  • @oriui/headless — the framework-agnostic core, and the roving helpers this binding reuses.
  • Color picker — the styled OriColorPicker built on this composable.
  • Slider — the native-range primitive the styled picker reuses for the hue / alpha channels.
  • Popover — compose the inline picker into a popover for a swatch-triggered flow.