Skip to content
Star

ColorPicker Ultra

ColorPickerWithGradient.vue
<script lang="ts" setup>
import { ref, computed, watch } from 'vue'
import { createReusableTemplate, useMediaQuery, useMounted } from '@vueuse/core'
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
import { PopoverContent, PopoverPortal, PopoverRoot } from 'reka-ui'
import { ColorPickerInputHex, ColorPickerInputHSL, ColorPickerInputRGB, ColorPickerInputHSB } from '@vuelor/picker'
import { ColorPickerRoot, ColorPickerCanvas, ColorPickerEyeDropper, ColorPickerSwatch } from '@vuelor/picker'
import { ColorPickerSliderHue, ColorPickerSliderAlpha, useVModel } from '@vuelor/picker'
import { GradientPickerRoot, GradientPickerSlider, GradientPickerPositionInput } from '@vuelor/gradient'
import { GradientPickerAddStop, GradientPickerRemoveStop, GradientPickerReverse, GradientPickerRotate } from '@vuelor/gradient'
import { normalizeHexa, parseGradient, serializeGradient } from '@vuelor/gradient'
import type { GradientType, UseGradientReturn } from '@vuelor/gradient'

import ColorPickerSelect from '../common/ColorPickerSelect.vue'

const [DefineColorPickerTemplate, ColorPicker] = createReusableTemplate()

const isDesktop = useMediaQuery('(min-width: 640px)')
// The media query resolves only in the browser; gating the mobile-only picker
// on mounted keeps server and first client render identical (no hydration
// mismatch) while still unmounting the duplicate instance on desktop.
const isMounted = useMounted()

const INPUTS = {
  Hex: ColorPickerInputHex,
  RGB: ColorPickerInputRGB,
  HSL: ColorPickerInputHSL,
  HSB: ColorPickerInputHSB
}

type ModelValue = string | null
type Format = keyof typeof INPUTS

interface Props {
  class?: string
  disabled?: boolean
  modelValue?: ModelValue
}

const props = withDefaults(defineProps<Props>(), {
  disabled: false,
  modelValue: null
})

const emit = defineEmits<{
  (e: 'update:modelValue', value: ModelValue): void
  (e: 'close'): void
}>()

const format = ref<Format>('Hex')
const formatOptions = Object.keys(INPUTS)

function handleFormatChange (value: string) {
  if (value in INPUTS) format.value = value as Format
}

const swatches = [
  '#00C3D0FF',
  '#00C8B3FF',
  '#34C759FF',
  '#FFCC00FF',
  '#FF383CFF',
  '#FF8D2825',
  '#FF383C40',
  '#FF8D2880',
  '#FFCC0080',
  '#34C759FF',
  '#00C8B3FF',
  '#00C3D0FF',
  '#0088FFFF',
  '#6155F5FF',
  '#CB30E0FF',
  '#FF2D55FF',
  '#FF2D5525',
  '#AC7F5EFF'
]

const canvasType = computed<'HSL' | 'HSV'>(() => {
  return format.value === 'HSL' ? 'HSL' : 'HSV'
})

const color = ref<string | null>(null)
const mode = ref<'color' | 'gradient'>('color')

// The gradient editor state (sorted stops, id-tracked selection, type, angle)
// lives inside GradientPickerRoot; this ref two-way binds its serialized CSS
// value and is what the gradient tab contributes to the external model.
const DEFAULT_GRADIENT = 'linear-gradient(90deg, #FF98C2FF 0%, #4DC1FFFF 33%, #D082E8FF 66%, #FFFA7AFF 100%)'
const gradientValue = ref<string>(DEFAULT_GRADIENT)

function handleModeChange (gradient: UseGradientReturn, value: string | number) {
  if (value !== 'color' && value !== 'gradient') return
  // Entering the color tab with no solid color yet: seed it from the selected
  // stop so the flip doesn't emit null (or the engine default) to the parent.
  if (value === 'color' && color.value === null) {
    color.value = gradient.selectedStop.value.color
  }
  mode.value = value
}

// The package models types as lowercase CSS keywords; the select shows them
// capitalized, matching the previous UI.
const GRADIENT_TYPE_LABELS: Record<GradientType, string> = {
  linear: 'Linear',
  radial: 'Radial',
  conic: 'Conic'
}
const gradientTypeOptions = Object.values(GRADIENT_TYPE_LABELS)

function handleGradientTypeChange (gradient: UseGradientReturn, commit: () => void, value: string) {
  const type = value.toLowerCase() as GradientType
  if (!(type in GRADIENT_TYPE_LABELS)) return
  gradient.type.value = type
  // Switching the type is a finished interaction; the root's commitValue
  // dedupes, so a re-selection of the current type stays silent.
  commit()
}

function warnUnsupported (value: string): void {
  // Locally-typed access: consumers without vite/client ambient types would
  // fail typecheck on the Vite-specific `import.meta.env`.
  const env = (import.meta as { env?: { DEV?: boolean } }).env
  if (env?.DEV) {
    console.warn(
      `[ColorPickerWithGradient] Unsupported modelValue "${value}". Accepted: #hex colors and ` +
      'linear/radial/conic gradients with #hex stops, e.g. "linear-gradient(90deg, #FF0000FF 0%, #0000FFFF 100%)".'
    )
  }
}

function applyModelValue (value: ModelValue): void {
  if (!value) return
  const input = value.trim()

  // Plain hex color (color mode)
  if (input.startsWith('#')) {
    const hexa = normalizeHexa(input)
    if (!hexa) return warnUnsupported(value)
    mode.value = 'color'
    color.value = hexa
    return
  }

  // Gradient (gradient mode). All-or-nothing like before: an unparseable
  // value warns and leaves the editor untouched. Re-serializing hands
  // GradientPickerRoot the canonical form, so the emit echo settles in one
  // round instead of two.
  const parsed = parseGradient(input)
  if (!parsed) return warnUnsupported(value)
  mode.value = 'gradient'
  gradientValue.value = serializeGradient(parsed)
}

const externalModel = useVModel(props, emit, applyModelValue)

const modelValue = computed<ModelValue>(() => {
  return mode.value === 'color' ? color.value : gradientValue.value
})

// No immediate flush: emitting during setup would overwrite the parent's value
// with our defaults before it was parsed. The null guard keeps an untouched
// color tab from nulling a bound model.
watch(modelValue, (newValue) => {
  if (newValue !== null) externalModel.value = newValue
})

// The picker edits the solid color in color mode and the selected stop (via
// the package's selectedColor bridge) in gradient mode. The picker's inputs
// round-trip alpha bit-exact and only emit on genuine changes, so a selection
// echo never loops.
function handlePickerColorChange (gradient: UseGradientReturn, value: unknown): void {
  if (typeof value !== 'string') return
  if (mode.value === 'color') {
    color.value = value
  } else {
    gradient.selectedColor.value = value
  }
}

function handleSelectStop (gradient: UseGradientReturn, id: number) {
  if (props.disabled) return
  gradient.select(id)
}

// The per-row hex fields emit when an edit lands (blur/Enter), so each
// emission is a finished stop-color interaction.
function handleStopColorChange (gradient: UseGradientReturn, commit: () => void, id: number, value: string) {
  gradient.setStopColor(id, value)
  commit()
}

// Canvas drags, sliders, inputs and swatches editing the selected stop
// signal their end through the picker's own valueCommit; forward it to the
// gradient's commit. In color mode the gradient editor is not involved.
function handlePickerColorCommit (commit: () => void) {
  if (mode.value === 'gradient') commit()
}

// The stop-color popover anchors to the panel (not the clicked stop swatch, which
// sits low in the list), so it always opens tidily to the left of the panel and
// top-aligned instead of hanging off — and over — the content below.
const panelRef = ref<HTMLElement>()
const STOP_POPOVER_SIDE_OFFSET = 12

// One shared popover for every stop, driven by the selection. Per-row popovers
// would close and reopen (a visible blink at the same anchored position) when
// clicking another stop's swatch; a single controlled popover stays mounted and
// its selection-bound content just swaps in place.
const isStopPopoverOpen = ref(false)

// "Was the popover already showing this stop?" must be sampled at pointerdown:
// the swatch's target-phase pointerdown runs before the row's bubbled mousedown
// selects the stop, so by click time the clicked stop is always the selected
// one and the toggle check could no longer tell swap from toggle-shut.
let swatchPressWasShowingStop = false

function handleStopSwatchPointerDown (gradient: UseGradientReturn, id: number) {
  swatchPressWasShowingStop = isStopPopoverOpen.value && gradient.selectedStopId.value === id
}

function handleStopSwatchClick (gradient: UseGradientReturn, id: number) {
  if (!isDesktop.value) {
    // Mobile has the inline picker; the swatch only selects.
    handleSelectStop(gradient, id)
    return
  }
  // Clicking the swatch of the stop the popover is showing toggles it shut;
  // any other swatch swaps the content in place. Keyboard activation (no
  // pointerdown) always opens; Escape is the keyboard close.
  if (swatchPressWasShowingStop) {
    isStopPopoverOpen.value = false
    swatchPressWasShowingStop = false
    return
  }
  handleSelectStop(gradient, id)
  isStopPopoverOpen.value = true
}

// Swatch clicks swap the popover content in place — they must not count as
// outside interactions. reka dismisses only when the event is not default-
// prevented, for both the pointer and focus outside paths.
function handleStopPopoverInteractOutside (event: CustomEvent<{ originalEvent: Event }>) {
  const target = event.detail?.originalEvent?.target
  if (target instanceof Element && target.closest('[data-stop-swatch]')) {
    event.preventDefault()
  }
}
</script>

<template>
  <GradientPickerRoot
    v-slot="{ gradient, commitValue }"
    v-model="gradientValue"
    :disabled="props.disabled"
    class="block w-auto gap-0 rounded-none bg-transparent p-0 shadow-none"
  >
    <ColorPickerRoot
      :model-value="mode === 'color' ? color : gradient.selectedColor.value"
      class="block p-0 text-black"
      :class="props.class"
      :disabled="props.disabled"
      :ui="{ input: { label: 'hidden', field: 'max-w-12' } }"
      @update:model-value="handlePickerColorChange(gradient, $event)"
      @value-commit="handlePickerColorCommit(commitValue)"
    >
      <DefineColorPickerTemplate>
        <div class="p-4 flex flex-col gap-2">
          <ColorPickerCanvas :type="canvasType" />
          <div class="flex items-center gap-3">
            <ColorPickerEyeDropper type="button" aria-label="Pick color from screen">
              <svg width="24" height="24" fill="none" viewBox="0 0 24 24">
                <path
                  fill="currentColor"
                  fill-rule="evenodd"
                  clip-rule="evenodd"
                  d="M17.52 6.471a1.62 1.62 0 0 0-2.295.003l-1.87 1.88-.354.355-.355-.354-.01-.01a.9.9 0 0 0-1.272 0l-.02.02a.9.9 0 0 0 0 1.273l.51.51 2 2 .51.51a.9.9 0 0 0 1.272 0l.02-.02a.9.9 0 0 0 0-1.273l-.01-.01-.352-.353.351-.353 1.879-1.888a1.62 1.62 0 0 0-.003-2.29m-3.004-.702a2.621 2.621 0 1 1 3.717 3.697l-1.57 1.579a1.9 1.9 0 0 1-.3 2.3l-.02.02a1.9 1.9 0 0 1-2.687 0l-.156-.157-5.647 5.642a.5.5 0 0 1-.353.147H5.504a.5.5 0 0 1-.5-.5L5 16.503a.5.5 0 0 1 .146-.354l5.647-5.647-.157-.156a1.9 1.9 0 0 1 0-2.687l.02-.02a1.9 1.9 0 0 1 2.299-.3zm-3.016 5.44 1.293 1.292-5.5 5.496h-1.29L6 16.707z"
                />
              </svg>
            </ColorPickerEyeDropper>
            <div class="flex flex-col flex-1 gap-2">
              <ColorPickerSliderHue />
              <ColorPickerSliderAlpha />
            </div>
          </div>
          <div class="flex items-center gap-2">
            <ColorPickerSelect
              :model-value="format"
              class="w-[56px]"
              label="Color format"
              placeholder="Format"
              :disabled="props.disabled"
              :options="formatOptions"
              @update:model-value="handleFormatChange"
            />
            <component :is="INPUTS[format]" />
          </div>
        </div>
        <div class="border-t px-3 py-2 grid grid-cols-9">
          <ColorPickerSwatch
            v-for="(swatch, i) in swatches"
            :key="i"
            :value="swatch"
            type="button"
            :aria-label="`Select color ${swatch}`"
            class="m-1"
          />
        </div>
      </DefineColorPickerTemplate>

      <TabsRoot
        :model-value="mode"
        @update:model-value="handleModeChange(gradient, $event)"
      >
        <div ref="panelRef" class="flex justify-between p-2 border-b">
          <TabsList class="flex gap-1">
            <TabsTrigger
              class="h-6 w-6 rounded-sm data-[state=active]:bg-vuelor-input"
              value="color"
              aria-label="Solid color"
              :disabled="props.disabled"
            >
              <svg width="24" height="24" fill="none" viewBox="0 0 24 24">
                <path fill="currentColor" fill-opacity="0.3" d="M9 9h6v6H9z" />
                <path fill="currentColor" fill-opacity="0.9" fill-rule="evenodd" clip-rule="evenodd" d="M8 7h8a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H8a1 1 0 0 1-1-1V8a1 1 0 0 1 1-1M6 8a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2zm3 7V9h6v6zM8 8.5a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-.5.5h-7a.5.5 0 0 1-.5-.5z" />
              </svg>
            </TabsTrigger>
            <TabsTrigger
              class="h-6 w-6 rounded-sm data-[state=active]:bg-vuelor-input"
              value="gradient"
              aria-label="Gradient"
              :disabled="props.disabled"
            >
              <svg width="24" height="24" fill="none" viewBox="0 0 24 24">
                <path fill="currentColor" fill-opacity="0.9" fill-rule="evenodd" clip-rule="evenodd" d="M8 7h8a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H8a1 1 0 0 1-1-1V8a1 1 0 0 1 1-1M6 8a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2zm3.75.875a.875.875 0 1 1-1.75 0 .875.875 0 0 1 1.75 0m3.791.625a.625.625 0 1 0 0-1.25.625.625 0 0 0 0 1.25m-1.458.875a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m0 3.12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.458 2.245a.625.625 0 1 0 0-1.25.625.625 0 0 0 0 1.25m.625-3.865a.625.625 0 1 1-1.25 0 .625.625 0 0 1 1.25 0M8.875 15.99a.875.875 0 1 0 0-1.75.875.875 0 0 0 0 1.75m.875-4.115a.875.875 0 1 1-1.75 0 .875.875 0 0 1 1.75 0m5.75-1a.5.5 0 1 0 0-1 .5.5 0 0 0 0 1m.5 2.623a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0" />
              </svg>
            </TabsTrigger>
          </TabsList>

          <button
            type="button"
            aria-label="Close"
            class="h-6 w-6 rounded-[5px] hover:bg-vuelor-input focus:outline focus:outline-vuelor-primary"
            @click="emit('close')"
          >
            <svg width="24" height="24" fill="none" viewBox="0 0 24 24">
              <path fill="currentColor" d="M16.224 7.082a.501.501 0 0 1 .694.693l-.065.078L12.707 12l4.146 4.146.064.078a.5.5 0 0 1-.693.694l-.078-.065L12 12.706l-4.147 4.147a.5.5 0 1 1-.707-.707l4.147-4.147-4.147-4.146-.064-.078a.501.501 0 0 1 .693-.693l.078.064L12 11.293l4.146-4.147z" />
            </svg>
          </button>
        </div>

        <TabsContent value="color">
          <ColorPicker />
        </TabsContent>
        <TabsContent class="pb-3" value="gradient">
          <div v-if="isMounted && !isDesktop" class="sm:hidden border-b">
            <ColorPicker />
          </div>
          <div class="h-12 pl-4 pr-2 flex items-center justify-between gap-2">
            <ColorPickerSelect
              :model-value="GRADIENT_TYPE_LABELS[gradient.type.value]"
              class="w-24"
              label="Gradient type"
              placeholder="Type"
              :disabled="props.disabled"
              :options="gradientTypeOptions"
              @update:model-value="handleGradientTypeChange(gradient, commitValue, $event)"
            />
            <div class="flex items-center gap-1">
              <GradientPickerReverse />
              <GradientPickerRotate />
            </div>
          </div>

          <div class="pt-4 px-4">
            <GradientPickerSlider />
          </div>
          <div class="h-8 pl-4 pr-2 mt-2 mb-1 flex items-center justify-between">
            <span class="text-[11px] font-bold">Stops</span>
            <GradientPickerAddStop label="Add stop" />
          </div>
          <div
            v-for="(stop, index) in gradient.stops.value"
            :key="stop.id"
            :class="{ 'bg-vuelor-primary/10': gradient.selectedStopId.value === stop.id }"
            class="h-8 pl-4 pr-2 flex items-center gap-2"
            @mousedown="handleSelectStop(gradient, stop.id)"
            @focusin="handleSelectStop(gradient, stop.id)"
          >
            <GradientPickerPositionInput
              :stop-id="stop.id"
              :label="`Stop ${index + 1} position`"
            />
            <ColorPickerInputHex
              class="flex-1"
              :model-value="stop.color"
              @update:model-value="handleStopColorChange(gradient, commitValue, stop.id, $event)"
            >
              <template #before>
                <ColorPickerSwatch
                  :value="stop.color"
                  type="button"
                  data-stop-swatch
                  aria-haspopup="dialog"
                  :aria-expanded="isStopPopoverOpen && gradient.selectedStopId.value === stop.id"
                  :aria-label="`Edit stop ${index + 1} color`"
                  @pointerdown="handleStopSwatchPointerDown(gradient, stop.id)"
                  @click="handleStopSwatchClick(gradient, stop.id)"
                />
              </template>
            </ColorPickerInputHex>
            <GradientPickerRemoveStop
              :stop-id="stop.id"
              :label="`Remove stop ${index + 1}`"
              :style="{ visibility: gradient.stops.value.length <= gradient.minStops ? 'hidden' : undefined }"
              @pointerdown.prevent
            />
          </div>

          <!-- Shared stop-color popover: stays mounted while open; clicking another
               stop's swatch swaps the selection-bound picker content in place. -->
          <PopoverRoot v-model:open="isStopPopoverOpen">
            <PopoverPortal v-if="isDesktop">
              <PopoverContent
                :reference="panelRef"
                side="left"
                align="start"
                :sideOffset="STOP_POPOVER_SIDE_OFFSET"
                data-vuelor-docs
                class="bg-vuelor-surface text-black w-60 z-50 rounded-lg shadow-vuelor-card"
                @interact-outside="handleStopPopoverInteractOutside"
              >
                <ColorPicker />
              </PopoverContent>
            </PopoverPortal>
          </PopoverRoot>
        </TabsContent>
      </TabsRoot>
    </ColorPickerRoot>
  </GradientPickerRoot>
</template>

This example is built on @vuelor/gradient, which provides the gradient parsing, the stops slider and the stop controls on top of @vuelor/picker.

Installed with the shadcn-vue CLI? Both packages and the --drop-shadow-vuelor-thumb token are added for you automatically. For a manual install, add @vuelor/gradient alongside @vuelor/picker, make sure Tailwind scans both packages, and add the gradient thumb shadow in index.css (TailwindCSS 4):

css
@import "tailwindcss";

@theme {
  /* Used for gradient picker slider thumbs only */
  --drop-shadow-vuelor-thumb: 0px 0px .5px #00000054, 0px 1px 3px #00000026;
}

or tailwind.config.js (TailwindCSS 3):

js
export default {
  theme: {
    extend: {
      dropShadow: {
        /* Used for gradient picker slider thumbs only */
        'vuelor-thumb': ['0px 0px .5px #00000054', '0px 1px 3px #00000026']
      }
    },
  }
}

Released under the MIT License.