Skip to content

ColorPicker

Demo

This requires the following theme to be installed:

Component

ColorPicker.vue
vue
<script lang="ts">
import type { MaybeRefOrGetter } from '@vueuse/shared'
import type { HSLObject } from 'colortranslator'
import type { VariantProps } from 'tailwind-variants'
import theme from '@/ui/theme/color-picker'
import { useElementBounding, useEventListener, watchPausable, watchThrottled } from '@vueuse/core'
import { isClient } from '@vueuse/shared'
import { ColorTranslator } from 'colortranslator'
import { Primitive } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { computed, nextTick, ref, toValue } from 'vue'

const colorPicker = tv(theme)

type ColorPickerVariants = VariantProps<typeof colorPicker>

interface HSVColor {
  h: number
  s: number
  v: number
}

function HSLtoHSV(hsl: HSLObject): HSVColor {
  const x = hsl.S * (hsl.L < 50 ? hsl.L : 100 - hsl.L)
  const v = hsl.L + (x / 100)
  return {
    h: hsl.H,
    s: hsl.L === 0 ? hsl.S : 2 * x / v,
    v,
  }
}

function HSVtoHSL(hsv: HSVColor): HSLObject {
  const x = (200 - hsv.s) * hsv.v / 100
  return {
    H: hsv.h,
    S: x === 0 || x === 200 ? 0 : Math.round(hsv.s * hsv.v / (x <= 100 ? x : 200 - x)),
    L: Math.round(x / 2),
  }
}

export interface ColorPickerProps {
  as?: any
  throttle?: number
  disabled?: boolean
  defaultValue?: string
  format?: 'hex' | 'rgb' | 'hsl' | 'cmyk' | 'lab'
  size?: ColorPickerVariants['size']
  class?: any
  ui?: Partial<typeof colorPicker.slots>
}
</script>

<script setup lang="ts">
const props = withDefaults(defineProps<ColorPickerProps>(), {
  format: 'hex',
  throttle: 50,
  defaultValue: '#FFFFFF',
})
const modelValue = defineModel<string>(undefined)

const pickedColor = computed<HSVColor>({
  get() {
    try {
      const color = new ColorTranslator(modelValue.value || props.defaultValue)

      return HSLtoHSV(color.HSLObject)
    }
    catch (_) {
      return { h: 0, s: 0, v: 100 }
    }
  },
  set(value) {
    const color = new ColorTranslator(HSVtoHSL(value), {
      decimals: 2,
      labUnit: 'percent',
      cmykUnit: 'percent',
      cmykFunction: 'cmyk',
    })

    switch (props.format) {
      case 'rgb':
        modelValue.value = color.RGB
        break
      case 'hsl':
        modelValue.value = color.HSL
        break
      case 'cmyk':
        modelValue.value = color.CMYK
        break
      case 'lab':
        modelValue.value = color.CIELab
        break
      case 'hex':
      default:
        modelValue.value = color.HEX
    }
  },
})

function useColorDraggable(targetElement: MaybeRefOrGetter<HTMLElement | null>, containerElement: MaybeRefOrGetter<HTMLElement | null>, axis: 'x' | 'y' | 'both' = 'both', initialPosition = { x: 0, y: 0 }, disabled?: MaybeRefOrGetter<boolean | undefined>,
) {
  const position = ref<{ x: number, y: number }>(initialPosition)
  const pressedDelta = ref<{ x: number, y: number }>()
  const targetRect = useElementBounding(targetElement)
  const containerRect = useElementBounding(containerElement)

  function start(event: PointerEvent) {
    if (toValue(disabled))
      return event.preventDefault()

    const container = toValue(containerElement)

    pressedDelta.value = {
      x: event.clientX - (container ? event.clientX - containerRect.left.value + container.scrollLeft : targetRect.left.value),
      y: event.clientY - (container ? event.clientY - containerRect.top.value + container.scrollTop : targetRect.top.value),
    }

    move(event)
  }

  function move(event: PointerEvent) {
    if (!pressedDelta.value)
      return

    const container = toValue(containerElement)
    let { x, y } = position.value

    if (container && (axis === 'x' || axis === 'both')) {
      x = Math.min(Math.max(0, (event.clientX - pressedDelta.value.x) / container.scrollWidth * 100), 100)
    }

    if (container && (axis === 'y' || axis === 'both')) {
      y = Math.min(Math.max(0, (event.clientY - pressedDelta.value.y) / container.scrollHeight * 100), 100)
    }

    position.value = { x, y }
  }

  function end() {
    if (!pressedDelta.value) {
      return
    }

    pressedDelta.value = undefined
  }

  if (isClient) {
    useEventListener(containerElement, 'pointerdown', start)
    useEventListener(window, 'pointermove', move)
    useEventListener(window, 'pointerup', end)
  }

  return {
    position,
  }
}

function normalizeHue(hue: number, dir: 'left' | 'right' = 'left'): number {
  if (dir === 'right') {
    return (hue * 100) / 360
  }

  return (hue / 100) * 360
}

function normalizeBrightness(brightness: number): number {
  return 100 - brightness
}

const selectorRef = ref<HTMLDivElement | null>(null)
const selectorThumbRef = ref<HTMLDivElement | null>(null)
const trackRef = ref<HTMLDivElement | null>(null)
const trackThumbRef = ref<HTMLDivElement | null>(null)

const disabled = computed(() => props.disabled)

const { position: selectorThumbPosition } = useColorDraggable(selectorThumbRef, selectorRef, 'both', {
  x: pickedColor.value.s,
  y: normalizeBrightness(pickedColor.value.v),
}, disabled)

const { position: trackThumbPosition } = useColorDraggable(trackThumbRef, trackRef, 'y', {
  x: 0,
  y: normalizeHue(pickedColor.value.h, 'right'),
}, disabled)

const { pause: pauseWatchColor, resume: resumeWatchColor } = watchPausable(pickedColor, (hsb) => {
  selectorThumbPosition.value = {
    x: hsb.s,
    y: normalizeBrightness(hsb.v),
  }
  trackThumbPosition.value = {
    x: 0,
    y: normalizeHue(hsb.h, 'right'),
  }
})

watchThrottled([selectorThumbPosition, trackThumbPosition], () => {
  pauseWatchColor()

  pickedColor.value = {
    h: normalizeHue(trackThumbPosition.value.y),
    s: selectorThumbPosition.value.x,
    v: normalizeBrightness(selectorThumbPosition.value.y),
  }

  nextTick(resumeWatchColor)
}, { throttle: () => props.throttle })

const trackThumbColor = computed(() => new ColorTranslator(HSVtoHSL({
  h: normalizeHue(trackThumbPosition.value.y),
  s: 100,
  v: 100,
})).HEX)

const selectorStyle = computed(() => ({
  backgroundColor: trackThumbColor.value,
}))

const selectorThumbStyle = computed(() => ({
  backgroundColor: new ColorTranslator(modelValue.value || props.defaultValue).HEX,
  left: `${selectorThumbPosition.value.x}%`,
  top: `${selectorThumbPosition.value.y}%`,
}))

const trackThumbStyle = computed(() => ({
  backgroundColor: trackThumbColor.value,
  top: `${trackThumbPosition.value.y}%`,
}))

const ui = computed(() => colorPicker({
  size: props.size,
}))
</script>

<template>
  <Primitive :as="as" :class="ui.root({ class: [props.class, props.ui?.root] })" :data-disabled="disabled ? true : undefined">
    <div :class="ui.picker({ class: props.ui?.picker })">
      <div
        ref="selectorRef"
        :class="ui.selector({ class: props.ui?.selector })"
        :style="selectorStyle"
      >
        <div :class="ui.selectorBackground({ class: props.ui?.selectorBackground })" data-color-picker-background>
          <div
            ref="selectorThumbRef"
            :class="ui.selectorThumb({ class: props.ui?.selectorThumb })"
            :style="selectorThumbStyle"
            :data-disabled="disabled ? true : undefined"
          />
        </div>
      </div>
      <div
        ref="trackRef"
        :class="ui.track({ class: props.ui?.track })"
        data-color-picker-track
      >
        <div
          ref="trackThumbRef"
          :class="ui.trackThumb({ class: props.ui?.trackThumb })"
          :style="trackThumbStyle"
          :data-disabled="disabled ? true : undefined"
        />
      </div>
    </div>
  </Primitive>
</template>

<style scoped>
[data-color-picker-background] {
  background-image: linear-gradient(to top, #000 0%, rgba(0, 0, 0, 0) 100%), linear-gradient(to right, #fff 0%, rgba(255, 255, 255, 0) 100%);
}

[data-color-picker-track] {
  background-image: linear-gradient(0deg, red 0, #f0f 17%, #00f 33%, #0ff 50%, #0f0 67%, #ff0 83%, red);
}
</style>
ColorPicker.vue
vue
<script lang="ts">
import type { MaybeRefOrGetter } from '@vueuse/shared'
import type { HSLObject } from 'colortranslator'
import type { VariantProps } from 'tailwind-variants'
import theme from '@/UI/Theme/color-picker'
import { useElementBounding, useEventListener, watchPausable, watchThrottled } from '@vueuse/core'
import { isClient } from '@vueuse/shared'
import { ColorTranslator } from 'colortranslator'
import { Primitive } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { computed, nextTick, ref, toValue } from 'vue'

const colorPicker = tv(theme)

type ColorPickerVariants = VariantProps<typeof colorPicker>

interface HSVColor {
  h: number
  s: number
  v: number
}

function HSLtoHSV(hsl: HSLObject): HSVColor {
  const x = hsl.S * (hsl.L < 50 ? hsl.L : 100 - hsl.L)
  const v = hsl.L + (x / 100)
  return {
    h: hsl.H,
    s: hsl.L === 0 ? hsl.S : 2 * x / v,
    v,
  }
}

function HSVtoHSL(hsv: HSVColor): HSLObject {
  const x = (200 - hsv.s) * hsv.v / 100
  return {
    H: hsv.h,
    S: x === 0 || x === 200 ? 0 : Math.round(hsv.s * hsv.v / (x <= 100 ? x : 200 - x)),
    L: Math.round(x / 2),
  }
}

export interface ColorPickerProps {
  as?: any
  throttle?: number
  disabled?: boolean
  defaultValue?: string
  format?: 'hex' | 'rgb' | 'hsl' | 'cmyk' | 'lab'
  size?: ColorPickerVariants['size']
  class?: any
  ui?: Partial<typeof colorPicker.slots>
}
</script>

<script setup lang="ts">
const props = withDefaults(defineProps<ColorPickerProps>(), {
  format: 'hex',
  throttle: 50,
  defaultValue: '#FFFFFF',
})
const modelValue = defineModel<string>(undefined)

const pickedColor = computed<HSVColor>({
  get() {
    try {
      const color = new ColorTranslator(modelValue.value || props.defaultValue)

      return HSLtoHSV(color.HSLObject)
    }
    catch (_) {
      return { h: 0, s: 0, v: 100 }
    }
  },
  set(value) {
    const color = new ColorTranslator(HSVtoHSL(value), {
      decimals: 2,
      labUnit: 'percent',
      cmykUnit: 'percent',
      cmykFunction: 'cmyk',
    })

    switch (props.format) {
      case 'rgb':
        modelValue.value = color.RGB
        break
      case 'hsl':
        modelValue.value = color.HSL
        break
      case 'cmyk':
        modelValue.value = color.CMYK
        break
      case 'lab':
        modelValue.value = color.CIELab
        break
      case 'hex':
      default:
        modelValue.value = color.HEX
    }
  },
})

function useColorDraggable(targetElement: MaybeRefOrGetter<HTMLElement | null>, containerElement: MaybeRefOrGetter<HTMLElement | null>, axis: 'x' | 'y' | 'both' = 'both', initialPosition = { x: 0, y: 0 }, disabled?: MaybeRefOrGetter<boolean | undefined>,
) {
  const position = ref<{ x: number, y: number }>(initialPosition)
  const pressedDelta = ref<{ x: number, y: number }>()
  const targetRect = useElementBounding(targetElement)
  const containerRect = useElementBounding(containerElement)

  function start(event: PointerEvent) {
    if (toValue(disabled))
      return event.preventDefault()

    const container = toValue(containerElement)

    pressedDelta.value = {
      x: event.clientX - (container ? event.clientX - containerRect.left.value + container.scrollLeft : targetRect.left.value),
      y: event.clientY - (container ? event.clientY - containerRect.top.value + container.scrollTop : targetRect.top.value),
    }

    move(event)
  }

  function move(event: PointerEvent) {
    if (!pressedDelta.value)
      return

    const container = toValue(containerElement)
    let { x, y } = position.value

    if (container && (axis === 'x' || axis === 'both')) {
      x = Math.min(Math.max(0, (event.clientX - pressedDelta.value.x) / container.scrollWidth * 100), 100)
    }

    if (container && (axis === 'y' || axis === 'both')) {
      y = Math.min(Math.max(0, (event.clientY - pressedDelta.value.y) / container.scrollHeight * 100), 100)
    }

    position.value = { x, y }
  }

  function end() {
    if (!pressedDelta.value) {
      return
    }

    pressedDelta.value = undefined
  }

  if (isClient) {
    useEventListener(containerElement, 'pointerdown', start)
    useEventListener(window, 'pointermove', move)
    useEventListener(window, 'pointerup', end)
  }

  return {
    position,
  }
}

function normalizeHue(hue: number, dir: 'left' | 'right' = 'left'): number {
  if (dir === 'right') {
    return (hue * 100) / 360
  }

  return (hue / 100) * 360
}

function normalizeBrightness(brightness: number): number {
  return 100 - brightness
}

const selectorRef = ref<HTMLDivElement | null>(null)
const selectorThumbRef = ref<HTMLDivElement | null>(null)
const trackRef = ref<HTMLDivElement | null>(null)
const trackThumbRef = ref<HTMLDivElement | null>(null)

const disabled = computed(() => props.disabled)

const { position: selectorThumbPosition } = useColorDraggable(selectorThumbRef, selectorRef, 'both', {
  x: pickedColor.value.s,
  y: normalizeBrightness(pickedColor.value.v),
}, disabled)

const { position: trackThumbPosition } = useColorDraggable(trackThumbRef, trackRef, 'y', {
  x: 0,
  y: normalizeHue(pickedColor.value.h, 'right'),
}, disabled)

const { pause: pauseWatchColor, resume: resumeWatchColor } = watchPausable(pickedColor, (hsb) => {
  selectorThumbPosition.value = {
    x: hsb.s,
    y: normalizeBrightness(hsb.v),
  }
  trackThumbPosition.value = {
    x: 0,
    y: normalizeHue(hsb.h, 'right'),
  }
})

watchThrottled([selectorThumbPosition, trackThumbPosition], () => {
  pauseWatchColor()

  pickedColor.value = {
    h: normalizeHue(trackThumbPosition.value.y),
    s: selectorThumbPosition.value.x,
    v: normalizeBrightness(selectorThumbPosition.value.y),
  }

  nextTick(resumeWatchColor)
}, { throttle: () => props.throttle })

const trackThumbColor = computed(() => new ColorTranslator(HSVtoHSL({
  h: normalizeHue(trackThumbPosition.value.y),
  s: 100,
  v: 100,
})).HEX)

const selectorStyle = computed(() => ({
  backgroundColor: trackThumbColor.value,
}))

const selectorThumbStyle = computed(() => ({
  backgroundColor: new ColorTranslator(modelValue.value || props.defaultValue).HEX,
  left: `${selectorThumbPosition.value.x}%`,
  top: `${selectorThumbPosition.value.y}%`,
}))

const trackThumbStyle = computed(() => ({
  backgroundColor: trackThumbColor.value,
  top: `${trackThumbPosition.value.y}%`,
}))

const ui = computed(() => colorPicker({
  size: props.size,
}))
</script>

<template>
  <Primitive :as="as" :class="ui.root({ class: [props.class, props.ui?.root] })" :data-disabled="disabled ? true : undefined">
    <div :class="ui.picker({ class: props.ui?.picker })">
      <div
        ref="selectorRef"
        :class="ui.selector({ class: props.ui?.selector })"
        :style="selectorStyle"
      >
        <div :class="ui.selectorBackground({ class: props.ui?.selectorBackground })" data-color-picker-background>
          <div
            ref="selectorThumbRef"
            :class="ui.selectorThumb({ class: props.ui?.selectorThumb })"
            :style="selectorThumbStyle"
            :data-disabled="disabled ? true : undefined"
          />
        </div>
      </div>
      <div
        ref="trackRef"
        :class="ui.track({ class: props.ui?.track })"
        data-color-picker-track
      >
        <div
          ref="trackThumbRef"
          :class="ui.trackThumb({ class: props.ui?.trackThumb })"
          :style="trackThumbStyle"
          :data-disabled="disabled ? true : undefined"
        />
      </div>
    </div>
  </Primitive>
</template>

<style scoped>
[data-color-picker-background] {
  background-image: linear-gradient(to top, #000 0%, rgba(0, 0, 0, 0) 100%), linear-gradient(to right, #fff 0%, rgba(255, 255, 255, 0) 100%);
}

[data-color-picker-track] {
  background-image: linear-gradient(0deg, red 0, #f0f 17%, #00f 33%, #0ff 50%, #0f0 67%, #ff0 83%, red);
}
</style>

Theme

color-picker.ts
ts
export default {
  slots: {
    root: '',
    picker: '',
    selector: '',
    selectorBackground: '',
    selectorThumb: '',
    track: '',
    trackThumb: '',
  },
  variants: {
    size: {
      xs: {
        selector: '',
        track: '',
      },
      sm: {
        selector: '',
        track: '',
      },
      md: {
        selector: '',
        track: '',
      },
      lg: {
        selector: '',
        track: '',
      },
      xl: {
        selector: '',
        track: '',
      },
    },
  },
  defaultVariants: {
    size: 'md',
  } as const,
}
View Nuxt UI theme
color-picker.ts
ts
export default {
  slots: {
    root: 'data-[disabled]:opacity-75',
    picker: 'flex gap-4',
    selector: 'rounded-md',
    selectorBackground: 'w-full h-full relative rounded-sm',
    selectorThumb: '-translate-y-1/2 -translate-x-1/2 absolute size-4 ring-2 ring-(--color-white) rounded-full cursor-pointer data-[disabled]:cursor-not-allowed',
    track: 'w-[8px] relative rounded-md',
    trackThumb: 'absolute transform -translate-y-1/2 -translate-x-[4px] rtl:translate-x-[4px] size-4 rounded-full ring-2 ring-(--color-white) cursor-pointer data-[disabled]:cursor-not-allowed',
  },
  variants: {
    size: {
      xs: {
        selector: 'w-38 h-38',
        track: 'h-38',
      },
      sm: {
        selector: 'w-40 h-40',
        track: 'h-40',
      },
      md: {
        selector: 'w-42 h-42',
        track: 'h-42',
      },
      lg: {
        selector: 'w-44 h-44',
        track: 'h-44',
      },
      xl: {
        selector: 'w-46 h-46',
        track: 'h-46',
      },
    },
  },
  defaultVariants: {
    size: 'md',
  } as const,
}

Test

To test this component, you can use the following test file:

ColorPicker.test.ts
ts
import type { RenderOptions } from '@testing-library/vue'
import ColorPicker from '@/ui/components/ColorPicker.vue'
import theme from '@/ui/theme/color-picker'
import { fireEvent, render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'

describe('color-picker', () => {
  const sizes = Object.keys(theme.variants.size) as any
  const formats = [
    ['hex', '#00C16A'],
    ['rgb', 'rgb(0, 193, 106)'],
    ['hsl', 'hsl(153, 100%, 37.8%)'],
    ['lab', 'lab(68.88% -60.41% 32.55%)'],
    ['cmyk', 'cmyk(100%, 0%, 45.08%, 24.31%)'],
  ]

  it.each<[string, RenderOptions<typeof ColorPicker>]>([
    // Props
    ['with disabled', { props: { disabled: true } }],
    ...sizes.map((size: string) => [`with size ${size}`, { props: { size } }]),
    ...formats.map(format => [`with format ${format[0]}`, { props: { format: format[0], defaultValue: format[1] } }]),
    ['with as', { props: { as: 'section' } }],
    ['with class', { props: { class: 'w-96' } }],
    ['with ui', { props: { ui: { picker: 'gap-8' } } }],
  ])(`renders %s correctly`, (name, options) => {
    render(ColorPicker, {
      attrs: {
        'data-testid': 'color-picker',
      },
      ...options,
    })

    expect(screen.getByTestId('color-picker')).matchSnapshot()
  })

  describe('emits', () => {
    it.todo('update:modelValue event', async () => {
      const { emitted } = render(ColorPicker)

      await fireEvent.drag(document.querySelector('[data-color-picker-track]>div')!, {
        delta: {
          y: -10,
        },
      })

      // FIXME: don't know why it does not works
      expect(emitted()).toMatchInlineSnapshot(`
        {
          "drag": [
            [
              Event {
                "isTrusted": false,
              },
            ],
          ],
        }
      `)
    })
  })
})
ColorPicker.test.ts
ts
import type { RenderOptions } from '@testing-library/vue'
import ColorPicker from '@/UI/Components/ColorPicker.vue'
import theme from '@/UI/Theme/color-picker'
import { fireEvent, render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'

describe('color-picker', () => {
  const sizes = Object.keys(theme.variants.size) as any
  const formats = [
    ['hex', '#00C16A'],
    ['rgb', 'rgb(0, 193, 106)'],
    ['hsl', 'hsl(153, 100%, 37.8%)'],
    ['lab', 'lab(68.88% -60.41% 32.55%)'],
    ['cmyk', 'cmyk(100%, 0%, 45.08%, 24.31%)'],
  ]

  it.each<[string, RenderOptions<typeof ColorPicker>]>([
    // Props
    ['with disabled', { props: { disabled: true } }],
    ...sizes.map((size: string) => [`with size ${size}`, { props: { size } }]),
    ...formats.map(format => [`with format ${format[0]}`, { props: { format: format[0], defaultValue: format[1] } }]),
    ['with as', { props: { as: 'section' } }],
    ['with class', { props: { class: 'w-96' } }],
    ['with ui', { props: { ui: { picker: 'gap-8' } } }],
  ])(`renders %s correctly`, (name, options) => {
    render(ColorPicker, {
      attrs: {
        'data-testid': 'color-picker',
      },
      ...options,
    })

    expect(screen.getByTestId('color-picker')).matchSnapshot()
  })

  describe('emits', () => {
    it.todo('update:modelValue event', async () => {
      const { emitted } = render(ColorPicker)

      await fireEvent.drag(document.querySelector('[data-color-picker-track]>div')!, {
        delta: {
          y: -10,
        },
      })

      // FIXME: don't know why it does not works
      expect(emitted()).toMatchInlineSnapshot(`
        {
          "drag": [
            [
              Event {
                "isTrusted": false,
              },
            ],
          ],
        }
      `)
    })
  })
})

Contributors

barbapapazes

Changelog

9acf7 - feat: update color picker to use colortranslator (#135) on 2/3/2025
c615c - feat: add custom eslint rule to disallow relative imports (#81) on 1/7/2025
f6703 - feat: add color picker component (#70) on 12/23/2024