Skip to content

Color Picker ​

HSV color picker with springy thumbs, hex/RGB/HSL fields, eyedropper, recents and a popover variant.

Recent

Theme

Popover variant: click a swatch.

Brand
Highlight
Live preview#0ea5e9

Installation ​

pnpm dlx shadcn-vue@latest add https://vuelix.dev/r/color-picker.json
Manual installation

Install the dependencies:

bash
npm install motion-v

Then copy these files into components/ui/color-picker/ (plus lib/utils.ts if you don't have it):

vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { ColorFormat, Hsva, Rgba } from './color'
import { AnimatePresence, motion } from 'motion-v'
import { computed, onMounted, ref, watch } from 'vue'
import { cn } from '@/lib/utils'
import { clamp, hslToHsv, hsvToHsl, hsvToRgb, parseColor, rgbToHsv, toHex } from './color'

const props = withDefaults(defineProps<{
  /** Show the opacity slider + alpha field. The model becomes `#rrggbbaa` when opacity < 100%. */
  alpha?: boolean
  /** Show the eyedropper button (only rendered where the browser supports `window.EyeDropper`). */
  eyedropper?: boolean
  /** How many recent colors to keep. `0` hides the row. */
  recentLimit?: number
  disabled?: boolean
  class?: HTMLAttributes['class']
}>(), {
  eyedropper: true,
  recentLimit: 7,
})

const emit = defineEmits<{
  /** Fires when an interaction finishes (pointer up, key up, field commit, eyedropper, swatch…). */
  change: [value: string]
}>()

const model = defineModel<string>({ default: '#6366f1' })
/** Most recent first. Bind it to keep the history across mounts (e.g. in a popover). */
const recent = defineModel<string[]>('recent', { default: () => [] })
const format = defineModel<ColorFormat>('format', { default: 'hex' })

// Tune the feel here.
const thumbSpring = { type: 'spring', stiffness: 520, damping: 22 } as const
const fadeSpring = { type: 'spring', stiffness: 600, damping: 40 } as const
const popSpring = { type: 'spring', stiffness: 600, damping: 18 } as const
const formatSpring = { type: 'spring', stiffness: 500, damping: 36 } as const

// Thumbs and the saturation/brightness gradients sit on the picked color itself, never on a
// themed surface, so they use fixed white/black (that *is* the HSV model) instead of tokens.
const THUMB_EDGE = '0 0 0 2px #fff, 0 0 0 3px rgb(0 0 0 / 0.18), 0 2px 6px rgb(0 0 0 / 0.3)'
const SV_OVERLAY = 'linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, transparent)'
const HUE_TRACK = 'linear-gradient(to right, #f00 0%, #ff0 16.67%, #0f0 33.33%, #0ff 50%, #00f 66.67%, #f0f 83.33%, #f00 100%)'
// Transparency checkerboard drawn with the theme's border token.
const CHECKER = {
  backgroundColor: 'var(--color-background)',
  backgroundImage: 'repeating-conic-gradient(var(--color-border) 0 25%, transparent 0 50%)',
  backgroundSize: '8px 8px',
}

// The picker's source of truth is HSV, so the hue survives greys and pure black/white.
function toHsva(value: string, hue = 0): Hsva {
  const rgb = parseColor(value) ?? { r: 0, g: 0, b: 0, a: 1 }
  return rgbToHsv({ ...rgb, a: props.alpha ? rgb.a : 1 }, hue)
}

const hsva = ref<Hsva>(toHsva(model.value))
const rgba = computed(() => hsvToRgb(hsva.value))
const hex = computed(() => toHex(rgba.value, props.alpha))
const solidHex = computed(() => toHex(rgba.value))
const cssColor = computed(() => {
  const { r, g, b, a } = rgba.value
  return `rgb(${Math.round(r)} ${Math.round(g)} ${Math.round(b)} / ${a})`
})

let lastEmitted = hex.value
let committed = hex.value // last value sent with `change`
let remembered = hex.value // last value pushed to (or picked from) the recent row
const original = ref(hex.value)

watch(model, (value) => {
  if (value === lastEmitted)
    return
  hsva.value = toHsva(value, hsva.value.h)
  lastEmitted = value
  committed = remembered = hex.value
})

function set(next: Partial<Hsva>) {
  hsva.value = { ...hsva.value, ...next }
  lastEmitted = hex.value
  model.value = hex.value
}

/**
 * Ends an interaction: emits `change`, and with `remember` records the color in the recent row.
 * Keyboard nudges only emit; the slider remembers on blur so tapping arrows doesn't flood recents.
 */
function commit(remember = true) {
  if (hex.value !== committed) {
    committed = hex.value
    emit('change', hex.value)
  }
  if (!remember || hex.value === remembered)
    return
  remembered = hex.value
  if (props.recentLimit > 0)
    recent.value = [hex.value, ...recent.value.filter(c => c !== hex.value)].slice(0, props.recentLimit)
}

// ---------------------------------------------------------------------------
// 2D area + sliders: pointer and keyboard
// ---------------------------------------------------------------------------
type Control = 'sv' | 'hue' | 'alpha'
const dragging = ref<Control | null>(null)

function applyPointer(control: Control, e: PointerEvent, el: HTMLElement) {
  const rect = el.getBoundingClientRect()
  const x = clamp((e.clientX - rect.left) / rect.width, 0, 1)
  const y = clamp((e.clientY - rect.top) / rect.height, 0, 1)
  if (control === 'sv')
    set({ s: x * 100, v: (1 - y) * 100 })
  else if (control === 'hue')
    set({ h: x * 360 })
  else
    set({ a: x })
}

function onPointerDown(control: Control, e: PointerEvent) {
  if (props.disabled || e.button !== 0)
    return
  const el = e.currentTarget as HTMLElement
  el.setPointerCapture(e.pointerId)
  // No preventDefault: the native mousedown focuses the control without a keyboard focus ring.
  dragging.value = control
  applyPointer(control, e, el)
}

function onPointerMove(control: Control, e: PointerEvent) {
  if (dragging.value === control)
    applyPointer(control, e, e.currentTarget as HTMLElement)
}

function onPointerUp(control: Control) {
  if (dragging.value !== control)
    return
  dragging.value = null
  commit()
}

const NAV_KEYS = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End']

function onKeydown(control: Control, e: KeyboardEvent) {
  if (props.disabled || !NAV_KEYS.includes(e.key))
    return
  e.preventDefault()
  const step = e.shiftKey || e.key.startsWith('Page') ? 10 : 1
  const { h, s, v, a } = hsva.value
  if (control === 'sv') {
    // Left/right → saturation, up/down (and PageUp/PageDown) → brightness.
    const dx = { ArrowLeft: -step, ArrowRight: step } as Record<string, number>
    const dy = { ArrowDown: -step, ArrowUp: step, PageDown: -step, PageUp: step } as Record<string, number>
    if (e.key === 'Home')
      set({ s: 0 })
    else if (e.key === 'End')
      set({ s: 100 })
    else
      set({ s: clamp(s + (dx[e.key] ?? 0), 0, 100), v: clamp(v + (dy[e.key] ?? 0), 0, 100) })
    return
  }
  const [value, max] = control === 'hue' ? [h, 360] : [a * 100, 100]
  const delta = ['ArrowLeft', 'ArrowDown', 'PageDown'].includes(e.key) ? -step : step
  const next = e.key === 'Home' ? 0 : e.key === 'End' ? max : clamp(Math.round(value) + delta, 0, max)
  set(control === 'hue' ? { h: next } : { a: next / 100 })
}

function onKeyup(e: KeyboardEvent) {
  if (NAV_KEYS.includes(e.key))
    commit(false)
}

const round = Math.round
const svText = computed(() => `Saturation ${round(hsva.value.s)}%, brightness ${round(hsva.value.v)}%`)

// ---------------------------------------------------------------------------
// Text fields (hex / rgb / hsl)
// ---------------------------------------------------------------------------
interface Field {
  key: 'hex' | 'r' | 'g' | 'b' | 'h' | 's' | 'l' | 'a'
  label: string
  name: string
  value: string
  max?: number
}

const formats: { value: ColorFormat, label: string }[] = [
  { value: 'hex', label: 'HEX' },
  { value: 'rgb', label: 'RGB' },
  { value: 'hsl', label: 'HSL' },
]

// The format pill slides by index × 46px (w-11 segment + 2px gap).
const formatIndex = computed(() => Math.max(0, formats.findIndex(f => f.value === format.value)))

const fields = computed<Field[]>(() => {
  const { r, g, b, a } = rgba.value
  const hsl = hsvToHsl(hsva.value)
  const list: Field[] = format.value === 'hex'
    ? [{ key: 'hex', label: 'Hex', name: 'Hex color', value: solidHex.value.slice(1).toUpperCase() }]
    : format.value === 'rgb'
      ? [
          { key: 'r', label: 'R', name: 'Red', value: `${round(r)}`, max: 255 },
          { key: 'g', label: 'G', name: 'Green', value: `${round(g)}`, max: 255 },
          { key: 'b', label: 'B', name: 'Blue', value: `${round(b)}`, max: 255 },
        ]
      : [
          { key: 'h', label: 'H', name: 'Hue', value: `${round(hsl.h)}`, max: 360 },
          { key: 's', label: 'S', name: 'Saturation', value: `${round(hsl.s)}`, max: 100 },
          { key: 'l', label: 'L', name: 'Lightness', value: `${round(hsl.l)}`, max: 100 },
        ]
  if (props.alpha)
    list.push({ key: 'a', label: 'A', name: 'Opacity', value: `${round(a * 100)}`, max: 100 })
  return list
})

// While a field is being typed in, it shows the raw draft instead of the formatted value.
const draft = ref<{ key: Field['key'], text: string } | null>(null)

function applyField(key: Field['key'], text: string, final: boolean) {
  if (key === 'hex') {
    const raw = text.trim().replace(/^#/, '')
    // Live-apply only complete values so typing "abc…" doesn't flash a shorthand color.
    if (!final && !/^(?:[\da-f]{6}|[\da-f]{8})$/i.test(raw))
      return
    const rgb = parseColor(/^[\da-f]+$/i.test(raw) ? `#${raw}` : text)
    if (rgb)
      set(rgbToHsv({ ...rgb, a: props.alpha && raw.length !== 6 ? rgb.a : hsva.value.a }, hsva.value.h))
    return
  }
  if (!/^\s*-?\d+(?:\.\d+)?\s*%?\s*$/.test(text))
    return
  const n = Number.parseFloat(text)
  if (key === 'a')
    return set({ a: clamp(n, 0, 100) / 100 })
  if (key === 'r' || key === 'g' || key === 'b') {
    const rgb: Rgba = { ...rgba.value, [key]: clamp(n, 0, 255) }
    return set(rgbToHsv(rgb, hsva.value.h))
  }
  const hsl = { ...hsvToHsl(hsva.value), [key]: clamp(n, 0, key === 'h' ? 360 : 100) }
  set(hslToHsv(hsl))
}

function onFieldInput(field: Field, e: Event) {
  const text = (e.target as HTMLInputElement).value
  draft.value = { key: field.key, text }
  applyField(field.key, text, false)
}

function finishField(field: Field) {
  if (draft.value?.key === field.key)
    applyField(field.key, draft.value.text, true)
  draft.value = null
  commit()
}

function onFieldKeydown(field: Field, e: KeyboardEvent) {
  if (e.key === 'Enter') {
    e.preventDefault()
    finishField(field)
  }
  else if (field.max !== undefined && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
    e.preventDefault()
    const step = (e.shiftKey ? 10 : 1) * (e.key === 'ArrowUp' ? 1 : -1)
    draft.value = null
    applyField(field.key, `${clamp(Number(field.value) + step, 0, field.max)}`, true)
  }
}

function onFormatKeydown(e: KeyboardEvent, index: number) {
  const delta = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0
  if (!delta)
    return
  e.preventDefault()
  const next = (index + delta + formats.length) % formats.length
  format.value = formats[next]!.value
  const group = (e.currentTarget as HTMLElement).parentElement
  ;(group?.children[next] as HTMLElement | undefined)?.focus()
}

// ---------------------------------------------------------------------------
// Eyedropper (Chromium only — feature-detected after mount, so SSR renders without it)
// ---------------------------------------------------------------------------
interface EyeDropperLike { open: () => Promise<{ sRGBHex: string }> }
type EyeDropperCtor = new () => EyeDropperLike

const canEyedrop = ref(false)
onMounted(() => {
  canEyedrop.value = 'EyeDropper' in window
})

async function pickFromScreen() {
  const Ctor = (window as unknown as { EyeDropper?: EyeDropperCtor }).EyeDropper
  if (!Ctor)
    return
  try {
    const { sRGBHex } = await new Ctor().open()
    const rgb = parseColor(sRGBHex)
    if (rgb) {
      set(rgbToHsv({ ...rgb, a: hsva.value.a }, hsva.value.h))
      commit()
    }
  }
  catch {
    // The user pressed Escape — nothing to do.
  }
}

// ---------------------------------------------------------------------------
// Preview + recent
// ---------------------------------------------------------------------------
const changed = computed(() => original.value !== hex.value)

/** Picking a recent swatch or reverting doesn't reshuffle the recent row. */
function useColor(value: string) {
  set(toHsva(value, hsva.value.h))
  remembered = hex.value
  commit()
}
</script>

<template>
  <div
    :class="cn(
      'flex w-72 select-none flex-col gap-3 rounded-2xl border bg-card p-3 text-card-foreground shadow-sm',
      props.disabled && 'pointer-events-none opacity-50',
      props.class,
    )"
    :aria-disabled="props.disabled || undefined"
  >
    <!-- Saturation (x) / brightness (y) -->
    <div
      role="slider"
      aria-label="Saturation and brightness"
      aria-roledescription="2D slider"
      :aria-valuenow="round(hsva.s)"
      aria-valuemin="0"
      aria-valuemax="100"
      :aria-valuetext="svText"
      :aria-disabled="props.disabled || undefined"
      :tabindex="props.disabled ? -1 : 0"
      class="relative h-40 w-full cursor-crosshair touch-none rounded-xl outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
      :style="{ backgroundColor: `hsl(${hsva.h} 100% 50%)` }"
      @pointerdown="onPointerDown('sv', $event)"
      @pointermove="onPointerMove('sv', $event)"
      @pointerup="onPointerUp('sv')"
      @pointercancel="onPointerUp('sv')"
      @keydown="onKeydown('sv', $event)"
      @keyup="onKeyup"
      @blur="commit()"
    >
      <div class="absolute inset-0 rounded-xl" :style="{ backgroundImage: SV_OVERLAY }" />
      <span class="pointer-events-none absolute size-0" :style="{ left: `${hsva.s}%`, top: `${100 - hsva.v}%` }">
        <motion.span
          class="absolute -left-2.5 -top-2.5 block size-5 rounded-full"
          :style="{ backgroundColor: solidHex, boxShadow: THUMB_EDGE }"
          :animate="{ scale: dragging === 'sv' ? 1.35 : 1 }"
          :transition="thumbSpring"
        />
      </span>
    </div>

    <div class="flex items-center gap-3">
      <!-- Old vs new. The "old" half slides in once the color differs; click it to revert. -->
      <div
        role="group"
        aria-label="Color preview"
        class="relative flex size-10 shrink-0 overflow-hidden rounded-lg"
        :style="CHECKER"
      >
        <motion.button
          type="button"
          :tabindex="changed ? 0 : -1"
          :aria-hidden="!changed || undefined"
          :aria-label="`Revert to original color ${original}`"
          :title="`Original ${original}`"
          class="h-full shrink-0 cursor-pointer outline-none focus-visible:ring-[3px] focus-visible:ring-inset focus-visible:ring-ring/50"
          :style="{ backgroundColor: original }"
          :initial="false"
          :animate="{ width: changed ? '50%' : '0%' }"
          :transition="fadeSpring"
          @click="useColor(original)"
        />
        <span class="h-full flex-1" :style="{ backgroundColor: cssColor }" />
        <span aria-hidden="true" class="pointer-events-none absolute inset-0 rounded-lg ring-1 ring-inset ring-foreground/10" />
      </div>

      <div class="flex flex-1 flex-col gap-3">
        <div
          role="slider"
          aria-label="Hue"
          :aria-valuenow="round(hsva.h)"
          aria-valuemin="0"
          aria-valuemax="360"
          :aria-valuetext="`Hue ${round(hsva.h)}°`"
          :aria-disabled="props.disabled || undefined"
          :tabindex="props.disabled ? -1 : 0"
          class="relative h-3 cursor-pointer touch-none rounded-full outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
          :style="{ backgroundImage: HUE_TRACK }"
          @pointerdown="onPointerDown('hue', $event)"
          @pointermove="onPointerMove('hue', $event)"
          @pointerup="onPointerUp('hue')"
          @pointercancel="onPointerUp('hue')"
          @keydown="onKeydown('hue', $event)"
          @keyup="onKeyup"
          @blur="commit()"
        >
          <span class="pointer-events-none absolute top-1/2 size-0" :style="{ left: `${(hsva.h / 360) * 100}%` }">
            <motion.span
              class="absolute -left-2 -top-2 block size-4 rounded-full"
              :style="{ backgroundColor: `hsl(${hsva.h} 100% 50%)`, boxShadow: THUMB_EDGE }"
              :animate="{ scale: dragging === 'hue' ? 1.35 : 1 }"
              :transition="thumbSpring"
            />
          </span>
        </div>

        <div
          v-if="props.alpha"
          role="slider"
          aria-label="Opacity"
          :aria-valuenow="round(hsva.a * 100)"
          aria-valuemin="0"
          aria-valuemax="100"
          :aria-valuetext="`Opacity ${round(hsva.a * 100)}%`"
          :aria-disabled="props.disabled || undefined"
          :tabindex="props.disabled ? -1 : 0"
          class="relative h-3 cursor-pointer touch-none rounded-full outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
          :style="CHECKER"
          @pointerdown="onPointerDown('alpha', $event)"
          @pointermove="onPointerMove('alpha', $event)"
          @pointerup="onPointerUp('alpha')"
          @pointercancel="onPointerUp('alpha')"
          @keydown="onKeydown('alpha', $event)"
          @keyup="onKeyup"
          @blur="commit()"
        >
          <span class="absolute inset-0 rounded-full" :style="{ backgroundImage: `linear-gradient(to right, transparent, ${solidHex})` }" />
          <span class="pointer-events-none absolute top-1/2 size-0" :style="{ left: `${hsva.a * 100}%` }">
            <motion.span
              class="absolute -left-2 -top-2 block size-4 rounded-full"
              :style="{ ...CHECKER, boxShadow: THUMB_EDGE }"
              :animate="{ scale: dragging === 'alpha' ? 1.35 : 1 }"
              :transition="thumbSpring"
            >
              <span class="absolute inset-0 rounded-full" :style="{ backgroundColor: cssColor }" />
            </motion.span>
          </span>
        </div>
      </div>
    </div>

    <div class="flex flex-col gap-2">
      <div class="flex items-center justify-between gap-2">
        <div role="radiogroup" aria-label="Color format" class="relative inline-flex gap-0.5 rounded-lg bg-muted p-0.5">
          <!-- Fixed-width segments, so the pill just slides by index (no layout projection,
               which would misplace it while a parent popover is still scaling in). -->
          <motion.span
            aria-hidden="true"
            class="absolute left-0.5 top-0.5 h-6 w-11 rounded-md bg-background shadow-sm"
            :initial="false"
            :animate="{ x: formatIndex * 46 }"
            :transition="formatSpring"
          />
          <button
            v-for="(f, i) in formats"
            :key="f.value"
            type="button"
            role="radio"
            :aria-checked="format === f.value"
            :tabindex="format === f.value ? 0 : -1"
            :disabled="props.disabled"
            :class="cn(
              'relative inline-flex h-6 w-11 items-center justify-center rounded-md text-[11px] font-semibold tracking-wide outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50',
              format === f.value ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
            )"
            @click="format = f.value"
            @keydown="onFormatKeydown($event, i)"
          >
            {{ f.label }}
          </button>
        </div>

        <button
          v-if="props.eyedropper && canEyedrop"
          type="button"
          aria-label="Pick a color from the screen"
          title="Pick from screen"
          :disabled="props.disabled"
          class="inline-flex size-7 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
          @click="pickFromScreen"
        >
          <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
            <path d="m12 9-8.4 8.4a2 2 0 0 0-.6 1.4V21h2.2a2 2 0 0 0 1.4-.6L15 12" />
            <path d="m15.5 5.5 3-3a2.1 2.1 0 0 1 3 3l-3 3 .9.9a1.4 1.4 0 0 1-2 2L11.6 5.6a1.4 1.4 0 0 1 2-2z" />
          </svg>
        </button>
      </div>

      <!-- Switching format cross-fades the whole row of fields. -->
      <div class="relative">
        <AnimatePresence mode="popLayout" :initial="false">
          <motion.div
            :key="format"
            class="flex w-full gap-1.5"
            :initial="{ opacity: 0, y: 6, filter: 'blur(3px)' }"
            :animate="{ opacity: 1, y: 0, filter: 'blur(0px)' }"
            :exit="{ opacity: 0, y: -6, filter: 'blur(3px)' }"
            :transition="fadeSpring"
          >
            <label
              v-for="field in fields"
              :key="field.key"
              :class="cn('flex min-w-0 flex-col items-center gap-1', field.key === 'hex' ? 'flex-[3]' : 'flex-1')"
            >
              <!-- Box styles live on the wrapper so host resets on bare <input>s can't flatten them. -->
              <span
                :class="cn(
                  'flex h-8 w-full items-center rounded-md border bg-background font-mono text-xs transition-[box-shadow,border-color] focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50',
                  field.key === 'hex' ? 'pl-2.5 pr-2' : 'px-1',
                )"
              >
                <span v-if="field.key === 'hex'" aria-hidden="true" class="text-muted-foreground">#</span>
                <input
                  :value="draft?.key === field.key ? draft.text : field.value"
                  :aria-label="field.name"
                  :inputmode="field.key === 'hex' ? 'text' : 'numeric'"
                  :maxlength="field.key === 'hex' ? 32 : 5"
                  :disabled="props.disabled"
                  autocomplete="off"
                  spellcheck="false"
                  :class="cn(
                    'h-full w-full min-w-0 select-text bg-transparent uppercase tabular-nums outline-none',
                    field.key === 'hex' ? 'ml-0.5 text-left' : 'text-center',
                  )"
                  @input="onFieldInput(field, $event)"
                  @keydown="onFieldKeydown(field, $event)"
                  @blur="finishField(field)"
                  @focus="($event.target as HTMLInputElement).select()"
                >
              </span>
              <span class="text-[10px] font-medium text-muted-foreground">{{ field.label }}{{ field.key === 'a' ? ' %' : '' }}</span>
            </label>
          </motion.div>
        </AnimatePresence>
      </div>
    </div>

    <div v-if="props.recentLimit > 0" class="flex items-center gap-2 border-t pt-3">
      <span class="text-[11px] font-medium text-muted-foreground">Recent</span>
      <div class="flex min-h-6 flex-1 flex-wrap items-center gap-1.5">
        <span v-if="!recent.length" class="text-[11px] text-muted-foreground/70">Colors you pick show up here</span>
        <!-- The newest swatch pops in at the front; the others slide over to make room. -->
        <AnimatePresence mode="popLayout" :initial="false">
          <motion.button
            v-for="color in recent"
            :key="color"
            layout
            type="button"
            :aria-label="`Use ${color}`"
            :title="color"
            :disabled="props.disabled"
            class="relative size-6 shrink-0 cursor-pointer overflow-hidden rounded-md outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
            :style="CHECKER"
            :initial="{ scale: 0, opacity: 0 }"
            :animate="{ scale: 1, opacity: 1 }"
            :exit="{ scale: 0, opacity: 0 }"
            :while-hover="{ scale: 1.12 }"
            :while-press="{ scale: 0.92 }"
            :transition="popSpring"
            @click="useColor(color)"
          >
            <span class="absolute inset-0" :style="{ backgroundColor: color }" />
            <span
              aria-hidden="true"
              :class="cn('absolute inset-0 rounded-md ring-1 ring-inset ring-foreground/10', color === hex && 'ring-2 ring-foreground/60')"
            />
          </motion.button>
        </AnimatePresence>
      </div>
    </div>
  </div>
</template>
vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { ColorFormat } from './color'
import { AnimatePresence, motion, useDomRef } from 'motion-v'
import { computed, nextTick, onBeforeUnmount, ref, useId, watch } from 'vue'
import { cn } from '@/lib/utils'
import { clamp } from './color'
import ColorPicker from './ColorPicker.vue'

// The root renders a button plus a teleported panel, so forward attrs (id, data-*, …) to the button.
defineOptions({ inheritAttrs: false })

const props = withDefaults(defineProps<{
  /** Accessible name of the trigger and the popover. */
  label?: string
  /** Show the hex value next to the swatch in the trigger. */
  showValue?: boolean
  /** Horizontal alignment of the popover relative to the trigger. */
  align?: 'start' | 'center' | 'end'
  /** Forwarded to `ColorPicker`. */
  alpha?: boolean
  /** Forwarded to `ColorPicker`. */
  eyedropper?: boolean
  /** Forwarded to `ColorPicker`. */
  recentLimit?: number
  disabled?: boolean
  /** Classes for the trigger button. */
  class?: HTMLAttributes['class']
  /** Classes for the picker inside the popover. */
  contentClass?: HTMLAttributes['class']
}>(), {
  label: 'Pick a color',
  showValue: true,
  align: 'start',
  eyedropper: true,
  recentLimit: 7,
})

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

const model = defineModel<string>({ default: '#6366f1' })
const open = defineModel<boolean>('open', { default: false })
// Kept here (not in the picker) so history and format survive the picker unmounting on close.
const recent = defineModel<string[]>('recent', { default: () => [] })
const format = defineModel<ColorFormat>('format', { default: 'hex' })

const panelId = `vx-color-popover-${useId()}`
const trigger = ref<HTMLButtonElement>()
const panel = useDomRef()

const spring = { type: 'spring', stiffness: 520, damping: 34 } as const
const GAP = 8

const pos = ref({ top: 0, left: 0, originX: 0, above: false })
const origin = computed(() => `${pos.value.originX}px ${pos.value.above ? '100%' : '0%'}`)

const CHECKER = {
  backgroundColor: 'var(--color-background)',
  backgroundImage: 'repeating-conic-gradient(var(--color-border) 0 25%, transparent 0 50%)',
  backgroundSize: '8px 8px',
}

/** Places the popover under the trigger (or above it when there's no room) and clamps it to the viewport. */
function place() {
  const t = trigger.value
  if (!t)
    return
  const el = panel.value as HTMLElement | null
  // offsetWidth/Height ignore the scale transform, so measuring mid-animation is fine.
  const w = el?.offsetWidth ?? 288
  const h = el?.offsetHeight ?? 380
  const r = t.getBoundingClientRect()
  const above = r.bottom + GAP + h > window.innerHeight && r.top - GAP - h > 0
  const ideal = props.align === 'start' ? r.left : props.align === 'end' ? r.right - w : r.left + r.width / 2 - w / 2
  const left = clamp(ideal, GAP, Math.max(GAP, window.innerWidth - w - GAP))
  pos.value = {
    top: above ? r.top - GAP - h : r.bottom + GAP,
    left,
    originX: clamp(r.left + r.width / 2 - left, 0, w),
    above,
  }
}

function close(refocus = false) {
  open.value = false
  if (refocus)
    trigger.value?.focus({ preventScroll: true })
}

function onDocPointerDown(e: PointerEvent) {
  const target = e.target as Node
  const el = panel.value as HTMLElement | null
  if (el?.contains(target) || trigger.value?.contains(target))
    return
  close()
}

function onDocKeydown(e: KeyboardEvent) {
  if (e.key === 'Escape') {
    e.preventDefault()
    close(true)
  }
}

function listen(on: boolean) {
  if (on) {
    document.addEventListener('pointerdown', onDocPointerDown, true)
    document.addEventListener('keydown', onDocKeydown)
    window.addEventListener('resize', place)
    window.addEventListener('scroll', place, true)
  }
  else {
    document.removeEventListener('pointerdown', onDocPointerDown, true)
    document.removeEventListener('keydown', onDocKeydown)
    window.removeEventListener('resize', place)
    window.removeEventListener('scroll', place, true)
  }
}

watch(open, async (isOpen) => {
  if (typeof document === 'undefined')
    return
  listen(isOpen)
  if (!isOpen)
    return
  place()
  await nextTick()
  place()
  ;((panel.value as HTMLElement | null)?.querySelector('[role="slider"]') as HTMLElement | null)?.focus({ preventScroll: true })
}, { immediate: true })

onBeforeUnmount(() => {
  if (typeof document !== 'undefined')
    listen(false)
})
</script>

<template>
  <button
    v-bind="$attrs"
    ref="trigger"
    type="button"
    aria-haspopup="dialog"
    :aria-expanded="open"
    :aria-controls="open ? panelId : undefined"
    :aria-label="`${props.label}: ${model}`"
    :disabled="props.disabled"
    :class="cn(
      'inline-flex h-9 items-center gap-2 rounded-lg border bg-background pl-1.5 text-sm shadow-xs outline-none transition-colors hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50',
      props.showValue ? 'pr-3' : 'pr-1.5',
      props.class,
    )"
    @click="open = !open"
  >
    <span class="relative size-6 shrink-0 overflow-hidden rounded-md" :style="CHECKER">
      <span class="absolute inset-0" :style="{ backgroundColor: model }" />
      <span aria-hidden="true" class="absolute inset-0 rounded-md ring-1 ring-inset ring-foreground/10" />
    </span>
    <span v-if="props.showValue" class="font-mono text-xs uppercase tabular-nums">{{ model }}</span>
  </button>

  <Teleport to="body">
    <AnimatePresence>
      <motion.div
        v-if="open"
        :id="panelId"
        key="panel"
        ref="panel"
        role="dialog"
        :aria-label="props.label"
        class="fixed z-50"
        :style="{ top: `${pos.top}px`, left: `${pos.left}px`, transformOrigin: origin }"
        :initial="{ opacity: 0, scale: 0.6, y: pos.above ? 8 : -8 }"
        :animate="{ opacity: 1, scale: 1, y: 0 }"
        :exit="{ opacity: 0, scale: 0.85, y: pos.above ? 6 : -6, transition: { duration: 0.15 } }"
        :transition="spring"
      >
        <ColorPicker
          v-model="model"
          v-model:recent="recent"
          v-model:format="format"
          :alpha="props.alpha"
          :eyedropper="props.eyedropper"
          :recent-limit="props.recentLimit"
          :class="cn('bg-popover text-popover-foreground shadow-xl', props.contentClass)"
          @change="emit('change', $event)"
        />
      </motion.div>
    </AnimatePresence>
  </Teleport>
</template>
ts
// Tiny, dependency-free color math for the color picker.
//
// Conventions used everywhere in this file:
//   r, g, b  → 0–255 (integers once rounded)
//   h        → 0–360 degrees
//   s, v, l  → 0–100 percent
//   a        → 0–1

export interface Rgba { r: number, g: number, b: number, a: number }
export interface Hsva { h: number, s: number, v: number, a: number }
export interface Hsla { h: number, s: number, l: number, a: number }

export type ColorFormat = 'hex' | 'rgb' | 'hsl'

export const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n))

const toHexByte = (n: number) => Math.round(clamp(n, 0, 255)).toString(16).padStart(2, '0')

/** Parses `#rgb`, `#rgba`, `#rrggbb` or `#rrggbbaa` (the `#` is optional). Returns `null` if invalid. */
export function parseHex(input: string): Rgba | null {
  let hex = input.trim().replace(/^#/, '')
  if (!/^(?:[\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})$/i.test(hex))
    return null
  if (hex.length <= 4)
    hex = [...hex].map(c => c + c).join('')
  const n = (i: number) => Number.parseInt(hex.slice(i, i + 2), 16)
  return { r: n(0), g: n(2), b: n(4), a: hex.length === 8 ? n(6) / 255 : 1 }
}

/**
 * Lenient parser for pasted values: any hex form, `rgb()/rgba()` or `hsl()/hsla()`
 * (comma or space separated, optional `/ alpha`). Returns `null` if it can't make sense of it.
 */
export function parseColor(input: string): Rgba | null {
  const text = input.trim().toLowerCase()
  const fn = /^(rgba?|hsla?)\(([^)]*)\)$/.exec(text)
  if (!fn)
    return parseHex(text)
  const parts = fn[2]!.split(/[\s,/]+/).filter(Boolean)
  if (parts.length < 3 || parts.length > 4)
    return null
  const num = (p: string) => Number.parseFloat(p)
  const nums = parts.map(num)
  if (nums.some(Number.isNaN))
    return null
  const alphaPart = parts[3]
  const a = alphaPart === undefined ? 1 : clamp(alphaPart.endsWith('%') ? num(alphaPart) / 100 : num(alphaPart), 0, 1)
  if (fn[1]!.startsWith('rgb')) {
    const ch = (p: string) => clamp(p.endsWith('%') ? num(p) * 2.55 : num(p), 0, 255)
    return { r: ch(parts[0]!), g: ch(parts[1]!), b: ch(parts[2]!), a }
  }
  return hslToRgb({ h: ((nums[0]! % 360) + 360) % 360, s: clamp(nums[1]!, 0, 100), l: clamp(nums[2]!, 0, 100), a })
}

/** `#rrggbb`, or `#rrggbbaa` when `alpha` is true and the color isn't fully opaque. */
export function toHex({ r, g, b, a }: Rgba, alpha = false): string {
  const base = `#${toHexByte(r)}${toHexByte(g)}${toHexByte(b)}`
  return alpha && a < 1 ? base + toHexByte(a * 255) : base
}

export function hsvToRgb({ h, s, v, a }: Hsva): Rgba {
  const sat = s / 100
  const val = v / 100
  // f(n) walks the hue wheel once per channel (n = 5 → red, 3 → green, 1 → blue).
  const f = (n: number) => {
    const k = (n + h / 60) % 6
    return val - val * sat * Math.max(0, Math.min(k, 4 - k, 1))
  }
  return { r: f(5) * 255, g: f(3) * 255, b: f(1) * 255, a }
}

/** Hue of an rgb triple (0–360); `fallback` is returned for greys, where hue is undefined. */
function hueOf(r: number, g: number, b: number, max: number, delta: number, fallback: number) {
  if (delta === 0)
    return fallback
  let h: number
  if (max === r)
    h = ((g - b) / delta) % 6
  else if (max === g)
    h = (b - r) / delta + 2
  else
    h = (r - g) / delta + 4
  return (h * 60 + 360) % 360
}

/** `hue` is kept for greys (s = 0) so a picker's hue slider doesn't jump back to red. */
export function rgbToHsv({ r, g, b, a }: Rgba, hue = 0): Hsva {
  const [rn, gn, bn] = [r / 255, g / 255, b / 255]
  const max = Math.max(rn, gn, bn)
  const delta = max - Math.min(rn, gn, bn)
  return {
    h: hueOf(rn, gn, bn, max, delta, hue),
    s: max === 0 ? 0 : (delta / max) * 100,
    v: max * 100,
    a,
  }
}

export function rgbToHsl({ r, g, b, a }: Rgba, hue = 0): Hsla {
  const [rn, gn, bn] = [r / 255, g / 255, b / 255]
  const max = Math.max(rn, gn, bn)
  const min = Math.min(rn, gn, bn)
  const delta = max - min
  const l = (max + min) / 2
  return {
    h: hueOf(rn, gn, bn, max, delta, hue),
    s: delta === 0 ? 0 : (delta / (1 - Math.abs(2 * l - 1))) * 100,
    l: l * 100,
    a,
  }
}

export function hslToRgb({ h, s, l, a }: Hsla): Rgba {
  const sat = s / 100
  const lig = l / 100
  const k = sat * Math.min(lig, 1 - lig)
  const f = (n: number) => {
    const m = (n + h / 30) % 12
    return lig - k * Math.max(-1, Math.min(m - 3, 9 - m, 1))
  }
  return { r: f(0) * 255, g: f(8) * 255, b: f(4) * 255, a }
}

// HSV ↔ HSL directly, so editing in HSL never loses the hue through an rgb round-trip.
export function hsvToHsl({ h, s, v, a }: Hsva): Hsla {
  const sv = s / 100
  const vv = v / 100
  const l = vv * (1 - sv / 2)
  const sl = l === 0 || l === 1 ? 0 : (vv - l) / Math.min(l, 1 - l)
  return { h, s: sl * 100, l: l * 100, a }
}

export function hslToHsv({ h, s, l, a }: Hsla): Hsva {
  const sl = s / 100
  const ll = l / 100
  const v = ll + sl * Math.min(ll, 1 - ll)
  return { h, s: v === 0 ? 0 : 2 * (1 - ll / v) * 100, v: v * 100, a }
}

/** Relative luminance (0–1) — handy for picking a readable foreground on top of a color. */
export function luminance({ r, g, b }: Rgba): number {
  const lin = (c: number) => {
    const n = c / 255
    return n <= 0.03928 ? n / 12.92 : ((n + 0.055) / 1.055) ** 2.4
  }
  return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b)
}
ts
export { default as ColorPicker } from './ColorPicker.vue'
export { default as ColorPickerPopover } from './ColorPickerPopover.vue'
export type { ColorFormat } from './color'

Usage ​

ts
import { ColorPicker, ColorPickerPopover } from '@/components/ui/color-picker'
vue
<!-- inline -->
<ColorPicker v-model="color" v-model:recent="recent" alpha />

<!-- compact: a swatch button that opens the same picker in a popover -->
<ColorPickerPopover v-model="color" label="Brand color" />

The model is always a lowercase hex string: #rrggbb, or #rrggbbaa when alpha is on and the opacity is below 100%. Incoming values may be any hex form, rgb() or hsl(). The hex field also accepts pasted rgb() / hsl() strings.

Both components live in the same folder and share color.ts, a small set of hand-written conversions (parseColor, toHex, rgbToHsv, hsvToRgb, rgbToHsl, hslToRgb, …) you can import directly from ./color if you need them elsewhere.

API ​

ColorPicker ​

PropTypeDefaultDescription
v-modelstring'#6366f1'Current color as hex.
v-model:recentstring[][]Recent colors, newest first. A color is added when a drag ends, a field is committed, the eyedropper picks, or a slider loses focus after keyboard nudges.
v-model:format'hex' | 'rgb' | 'hsl''hex'Which fields are shown.
alphabooleanfalseAdds the opacity slider and an A field.
eyedropperbooleantrueShows the eyedropper button where window.EyeDropper exists (Chromium).
recentLimitnumber7Max recent colors; 0 hides the row.
disabledbooleanfalseDisables all controls.
EventPayloadDescription
@change(value: string)Fires once per finished interaction (pointer up, key up, field commit, eyedropper, revert), unlike update:modelValue which fires on every move.

ColorPickerPopover ​

Accepts v-model, v-model:recent, v-model:format, alpha, eyedropper, recentLimit, disabled and @change (forwarded to the picker), plus:

PropTypeDefaultDescription
v-model:openbooleanfalseWhether the popover is open.
labelstring'Pick a color'Accessible name for the trigger and the dialog.
showValuebooleantrueShow the hex value next to the swatch.
align'start' | 'center' | 'end''start'Horizontal alignment to the trigger. Flips above when there's no room below.
contentClassstring—Classes for the picker inside the popover.

Keyboard ​

KeySaturation / brightness areaHue & opacity sliders
← →Saturation ±1Value ±1
↑ ↓Brightness ±1Value ±1
Shift + arrows, PageUp / PageDownSteps of 10Steps of 10
Home / EndSaturation 0 / 100Min / max

Number fields step with ↑ / ↓ (Shift for 10) and commit on Enter or blur. In the popover, Escape or a click outside closes it and focus returns to the trigger on Escape.

Each control is a role="slider" with an aria-valuetext (e.g. "Saturation 72%, brightness 40%"), the format switch is a radio group, and the preview chip's left half ("old" color) reverts on click.

Designed by Kuldeep Pisda · Released under the MIT License