Colour picker

Choose a colour by dragging, by hue, from presets, or by typing its hex code.

Install

If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.

npx ermineui init
pnpm dlx ermineui init
yarn dlx ermineui init
bunx ermineui init

Then add the component:

npx ermineui add color-picker
pnpm dlx ermineui add color-picker
yarn dlx ermineui add color-picker
bunx ermineui add color-picker

That copies 3 files into src/components/ui/color-picker/, 3 helpers into src/lib/ermine/. Files you already have are kept.

Use

import { ColorPicker } from '@/components/ui/color-picker'
<script setup lang="ts">
import { ColorPicker } from '@/components/ui/color-picker'
</script>

<template>
  <ColorPicker />
</template>

In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island. The props are documented in the source below, on each one.

What gets copied

FileWhere it goes
ColorPicker.vuesrc/components/ui/color-picker/ColorPicker.vue
color.tssrc/components/ui/color-picker/color.ts
index.tssrc/components/ui/color-picker/index.ts
floating.ts helpersrc/lib/ermine/floating.ts
layer.ts helpersrc/lib/ermine/layer.ts
primitive.ts helpersrc/lib/ermine/primitive.ts

The folders are the suggested ones. ermine.json says where yours are.

Source

This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.

ColorPicker.vue 389 lines
<script setup lang="ts">
import { computed, onMounted, ref, useId, watch } from 'vue'
import { useFloating, type Align, type Side } from '../../lib/floating'
import { useLayer } from '../../lib/layer'
import { Portal } from '../../lib/primitive'
import { hexToHsv, hsvToHex, normalizeHex, type Hsv } from './color'

// Attributes such as id and aria-invalid go to the button, so <Label for="..."> works.
defineOptions({ inheritAttrs: false })

const props = withDefaults(
  defineProps<{
    /** Colours offered as one-press choices, as hex codes. */
    presets?: readonly string[]
    /** Submit the value with a form under this name. */
    name?: string
    disabled?: boolean
    /** Accessible name of the button. */
    label?: string
    side?: Side
    align?: Align
    sideOffset?: number
  }>(),
  { presets: () => [], label: 'Colour', side: 'bottom', align: 'start', sideOffset: 4 },
)

/** The colour as '#rrggbb'. */
const value = defineModel<string>({ default: '#2a78d6' })
const open = defineModel<boolean>('open', { default: false })

const anchor = ref<HTMLElement | null>(null)
const content = ref<HTMLElement | null>(null)
const area = ref<HTMLElement | null>(null)
const contentId = useId()

// Kept alongside the hex value: black, white and greys have no hue of their own, and without this
// the hue slider would snap back to red every time the colour passed through one of them.
const hsv = ref<Hsv>(hexToHsv(value.value))
const text = ref(value.value)

watch(value, (next) => {
  if (normalizeHex(next) !== hsvToHex(hsv.value)) hsv.value = hexToHsv(next)
  text.value = normalizeHex(next) ?? next
})

function set(next: Partial<Hsv>) {
  hsv.value = { ...hsv.value, ...next }
  value.value = hsvToHex(hsv.value)
}

const clamp = (number: number) => Math.max(0, Math.min(number, 1))
const hueColor = computed(() => hsvToHex({ h: hsv.value.h, s: 1, v: 1 }))

let dragging = false
function pick(event: PointerEvent) {
  const box = area.value?.getBoundingClientRect()
  if (!box) return
  set({ s: clamp((event.clientX - box.left) / box.width), v: clamp(1 - (event.clientY - box.top) / box.height) })
}

function onAreaDown(event: PointerEvent) {
  dragging = true
  ;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
  pick(event)
}

function onAreaKeydown(event: KeyboardEvent) {
  const step = event.shiftKey ? 0.1 : 0.01
  if (event.key === 'ArrowLeft') set({ s: clamp(hsv.value.s - step) })
  else if (event.key === 'ArrowRight') set({ s: clamp(hsv.value.s + step) })
  else if (event.key === 'ArrowUp') set({ v: clamp(hsv.value.v + step) })
  else if (event.key === 'ArrowDown') set({ v: clamp(hsv.value.v - step) })
  else return
  event.preventDefault()
}

function commitText() {
  const hex = normalizeHex(text.value)
  // Something that is not a colour is put back to the current one when the field is left.
  if (hex) value.value = hex
  else text.value = value.value
}

// Picking a colour from anywhere on the screen. Only some browsers have it (Chrome and Edge on
// desktop at the time of writing); the button is left out everywhere else.
const canPickFromScreen = ref(false)
onMounted(() => {
  canPickFromScreen.value = 'EyeDropper' in window
})

async function pickFromScreen() {
  try {
    const EyeDropper = (window as unknown as { EyeDropper: new () => { open: () => Promise<{ sRGBHex: string }> } }).EyeDropper
    const { sRGBHex } = await new EyeDropper().open()
    value.value = normalizeHex(sRGBHex) ?? value.value
  } catch {
    // Escape was pressed: nothing was picked, and nothing changes.
  }
}

function setOpen(next: boolean) {
  open.value = next && !props.disabled
}

useFloating(anchor, content, open, props)
useLayer(content, open, {
  loopFocus: true,
  ignore: () => [anchor.value],
  onDismiss: () => setOpen(false),
  initialFocus: () => area.value,
})
</script>

<template>
  <button
    ref="anchor"
    v-bind="$attrs"
    type="button"
    class="er-color-picker"
    aria-haspopup="dialog"
    :aria-expanded="open"
    :aria-controls="open ? contentId : undefined"
    :aria-label="`${label}: ${value}`"
    :disabled="disabled"
    @click="setOpen(!open)"
  >
    <span class="er-color-picker-swatch" :style="{ background: value }" />
    <!-- The code is always written out, so the choice never depends on being able to see the colour. -->
    <span class="er-color-picker-value">{{ value }}</span>
  </button>

  <Portal>
    <Transition name="er-pop">
      <div v-if="open" :id="contentId" ref="content" role="dialog" tabindex="-1" class="er-floating er-color-picker-content" :aria-label="label">
        <!-- A two-way slider: left / right is how vivid, up / down is how light. -->
        <div
          ref="area"
          role="slider"
          tabindex="0"
          class="er-color-picker-area"
          aria-label="Saturation and brightness"
          :aria-valuetext="`Saturation ${Math.round(hsv.s * 100)}%, brightness ${Math.round(hsv.v * 100)}%`"
          :aria-valuenow="Math.round(hsv.s * 100)"
          aria-valuemin="0"
          aria-valuemax="100"
          :style="{ '--er-color-hue': hueColor }"
          @pointerdown="onAreaDown"
          @pointermove="dragging && pick($event)"
          @pointerup="dragging = false"
          @pointercancel="dragging = false"
          @keydown="onAreaKeydown"
        >
          <span class="er-color-picker-thumb" :style="{ left: `${hsv.s * 100}%`, top: `${(1 - hsv.v) * 100}%`, background: value }" />
        </div>
        <input
          type="range"
          class="er-color-picker-hue"
          min="0"
          max="360"
          aria-label="Hue"
          :value="Math.round(hsv.h)"
          @input="set({ h: Number(($event.target as HTMLInputElement).value) })"
        />
        <div class="er-color-picker-row">
          <span class="er-color-picker-swatch" :style="{ background: value }" />
          <input v-model="text" type="text" class="er-color-picker-hex" aria-label="Hex code" spellcheck="false" autocapitalize="off" maxlength="7" @change="commitText" @keydown.enter.prevent="commitText" />
          <button v-if="canPickFromScreen" type="button" class="er-color-picker-eyedropper" aria-label="Pick a colour from the screen" title="Pick a colour from the screen" @click="pickFromScreen">
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
              <path d="m12 6 6 6M14 4l6 6-2.500 2.500-6-6L14 4ZM13 9l-8 8v3h3l8-8" />
            </svg>
          </button>
        </div>
        <div v-if="presets.length" class="er-color-picker-presets" role="group" aria-label="Preset colours">
          <button
            v-for="preset in presets"
            :key="preset"
            type="button"
            :aria-label="preset"
            :aria-pressed="normalizeHex(preset) === value"
            :style="{ background: preset }"
            @click="value = normalizeHex(preset) ?? value"
          />
        </div>
      </div>
    </Transition>
  </Portal>

  <input v-if="name" type="hidden" :name="name" :value="value" :disabled="disabled" />
</template>

<style>
@layer components {
  .er-color-picker {
    display: inline-flex;
    align-items: center;
    gap: 0.5rem;
    height: var(--er-control-md);
    padding: 0 0.75rem 0 0.5rem;
    border: 1px solid var(--er-input);
    border-radius: var(--er-radius-control);
    background: var(--er-input-background);
    color: var(--er-foreground);
    font-family: var(--er-font-mono);
    font-size: 0.8125rem;
    box-shadow: var(--er-shadow-xs);
    cursor: pointer;
  }

  .er-color-picker:focus-visible {
    border-color: var(--er-ring);
    outline: 2px solid color-mix(in oklab, var(--er-ring) 45%, transparent);
    outline-offset: 0;
  }

  .er-color-picker:disabled {
    opacity: 0.5;
    cursor: not-allowed;
  }

  .er-color-picker-swatch {
    flex-shrink: 0;
    width: 1.25rem;
    height: 1.25rem;
    border-radius: var(--er-radius-sm);
    box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--er-foreground) 15%, transparent);
  }

  .er-color-picker-content {
    display: grid;
    gap: 0.75rem;
    width: 15rem;
    padding: 0.75rem;
    border: 1px solid var(--er-border);
    border-radius: var(--er-radius-md);
    background: var(--er-popover);
    color: var(--er-popover-foreground);
    box-shadow: var(--er-shadow-md);
  }

  /* White to the hue across, with black rising from the bottom: every tint and shade of one hue. */
  .er-color-picker-area {
    position: relative;
    height: 9rem;
    border-radius: var(--er-radius-sm);
    background:
      linear-gradient(to top, #000, transparent),
      linear-gradient(to right, #fff, var(--er-color-hue));
    cursor: crosshair;
    touch-action: none;
    outline: none;
  }

  .er-color-picker-area:focus-visible {
    outline: 2px solid var(--er-ring);
    outline-offset: 2px;
  }

  .er-color-picker-thumb {
    position: absolute;
    width: 0.875rem;
    height: 0.875rem;
    border: 2px solid #fff;
    border-radius: 50%;
    box-shadow: 0 0 0 1px oklch(0 0 0 / 40%);
    transform: translate(-50%, -50%);
    pointer-events: none;
  }

  .er-color-picker-hue {
    appearance: none;
    display: block;
    width: 100%;
    height: 0.75rem;
    margin: 0;
    border-radius: 999px;
    background: linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00);
    cursor: pointer;
  }

  .er-color-picker-hue::-webkit-slider-thumb {
    appearance: none;
    width: 0.875rem;
    height: 0.875rem;
    border: 2px solid #fff;
    border-radius: 50%;
    background: transparent;
    box-shadow: 0 0 0 1px oklch(0 0 0 / 40%);
  }

  .er-color-picker-hue::-moz-range-thumb {
    box-sizing: border-box;
    width: 0.875rem;
    height: 0.875rem;
    border: 2px solid #fff;
    border-radius: 50%;
    background: transparent;
    box-shadow: 0 0 0 1px oklch(0 0 0 / 40%);
  }

  .er-color-picker-hue::-moz-range-track {
    background: transparent;
  }

  .er-color-picker-hue:focus-visible {
    outline: 2px solid var(--er-ring);
    outline-offset: 2px;
  }

  .er-color-picker-row {
    display: flex;
    align-items: center;
    gap: 0.5rem;
  }

  .er-color-picker-hex {
    flex: 1;
    min-width: 0;
    height: 2rem;
    padding: 0 0.5rem;
    border: 1px solid var(--er-input);
    border-radius: var(--er-radius-md);
    background: var(--er-input-background);
    color: var(--er-foreground);
    font-family: var(--er-font-mono);
    font-size: 0.8125rem;
  }

  .er-color-picker-hex:focus-visible {
    border-color: var(--er-ring);
    outline: 2px solid color-mix(in oklab, var(--er-ring) 45%, transparent);
    outline-offset: 0;
  }

  .er-color-picker-eyedropper {
    display: grid;
    flex-shrink: 0;
    place-items: center;
    width: 2rem;
    height: 2rem;
    padding: 0;
    border: 1px solid var(--er-input);
    border-radius: var(--er-radius-md);
    background: var(--er-input-background);
    color: var(--er-foreground);
    cursor: pointer;
  }

  .er-color-picker-eyedropper:hover {
    background: var(--er-accent);
  }

  .er-color-picker-eyedropper:focus-visible {
    outline: 2px solid var(--er-ring);
    outline-offset: 2px;
  }

  .er-color-picker-eyedropper svg {
    width: 1rem;
    height: 1rem;
  }

  .er-color-picker-presets {
    display: flex;
    flex-wrap: wrap;
    gap: 0.375rem;
  }

  .er-color-picker-presets button {
    width: 1.5rem;
    height: 1.5rem;
    padding: 0;
    border: 0;
    border-radius: var(--er-radius-sm);
    box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--er-foreground) 15%, transparent);
    cursor: pointer;
  }

  .er-color-picker-presets button[aria-pressed='true'] {
    outline: 2px solid var(--er-foreground);
    outline-offset: 2px;
  }

  .er-color-picker-presets button:focus-visible {
    outline: 2px solid var(--er-ring);
    outline-offset: 2px;
  }
}
</style>
color.ts 38 lines
/** Hue 0–360, saturation and value 0–1: the model the picker's square and slider work in. */
export interface Hsv {
  h: number
  s: number
  v: number
}

/** '#rrggbb' from '#rgb', '#rrggbb' or the same without the hash; undefined for anything else. */
export function normalizeHex(input: string): string | undefined {
  const text = input.trim().replace(/^#/, '').toLowerCase()
  if (/^[0-9a-f]{3}$/.test(text)) return `#${[...text].map((char) => char + char).join('')}`
  return /^[0-9a-f]{6}$/.test(text) ? `#${text}` : undefined
}

export function hexToHsv(hex: string): Hsv {
  const value = normalizeHex(hex) ?? '#000000'
  const [r, g, b] = [1, 3, 5].map((start) => Number.parseInt(value.slice(start, start + 2), 16) / 255) as [number, number, number]
  const max = Math.max(r, g, b)
  const delta = max - Math.min(r, g, b)
  let h = 0
  if (delta) {
    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: (h * 60 + 360) % 360, s: max ? delta / max : 0, v: max }
}

export function hsvToHex({ h, s, v }: Hsv): string {
  const channel = (offset: number) => {
    const k = (offset + h / 60) % 6
    return Math.round((v - v * s * Math.max(0, Math.min(k, 4 - k, 1))) * 255)
      .toString(16)
      .padStart(2, '0')
  }
  return `#${channel(5)}${channel(3)}${channel(1)}`
}
index.ts 3 lines
/** Colour picker — choose a colour by dragging, by hue, from presets, or by typing its hex code. */
export { default as ColorPicker } from './ColorPicker.vue'