Skip to content

Build Material UI Color Picker

Let's build a Material-style color picker with a color area, hue slider, alpha slider, and channel input fields.

Here's what we'll end up with:

Click to view the full code
vue
<script setup lang="ts">
import { computed } from "vue";
import "internationalized-color/css";
import { colorSpaces } from "@urcolor/core";
import { Label } from "reka-ui";
import {
  useColor,
  ColorAreaRoot,
  ColorAreaTrack,
  ColorAreaGradient,
  ColorAreaThumb,
  ColorAreaThumbX,
  ColorAreaThumbY,
  ColorSliderRoot,
  ColorSliderTrack,
  ColorSliderGradient,
  ColorSliderThumb,
  ColorSliderCheckerboard,
  ColorFieldRoot,
  ColorFieldInput,
  ColorFieldIncrement,
  ColorFieldDecrement,
} from "@urcolor/vue";

const { color } = useColor("hsl(210, 80%, 50%)");
const channels = computed(() => colorSpaces["hsl"]?.channels ?? []);
</script>

<template>
  <div class="flex w-full max-w-xs flex-col gap-3 rounded-xl border border-(--vp-c-divider) bg-(--vp-c-bg) p-3 shadow-sm">
    <ColorAreaRoot
      v-model="color"
      color-space="hsv"
      channel-x="s"
      channel-y="v"
      :inverted-y="true"
      as="div"
      class="block"
    >
      <ColorAreaTrack
        as="div"
        class="relative h-[180px] w-full cursor-crosshair touch-none overflow-clip rounded-lg"
      >
        <ColorAreaGradient
          as="div"
          class="absolute inset-0"
        />
        <ColorAreaThumb
          as="div"
          class="
            absolute size-5 transform-(--reka-slider-area-thumb-transform)
            rounded-full border-2 border-white
            shadow-[0_0_0_1px_rgba(0,0,0,0.3),0_2px_4px_rgba(0,0,0,0.3)]
            focus-visible:shadow-[0_0_0_1px_rgba(0,0,0,0.3),0_0_0_3px_rgba(66,153,225,0.6)]
          "
        >
          <ColorAreaThumbX class="outline-none" />
          <ColorAreaThumbY class="outline-none" />
        </ColorAreaThumb>
      </ColorAreaTrack>
    </ColorAreaRoot>

    <ColorSliderRoot
      v-model="color"
      color-space="hsv"
      channel="h"
      as="div"
      class="w-full"
    >
      <ColorSliderTrack
        as="div"
        class="relative h-4 overflow-hidden rounded-full"
      >
        <ColorSliderGradient
          as="div"
          class="absolute inset-0 rounded-full"
          :colors="['red', 'yellow', 'lime', 'cyan', 'blue', 'magenta', 'red']"
        />
        <ColorSliderThumb
          class="
            block size-4 rounded-full border-2 border-white
            shadow-[0_0_0_1px_rgba(0,0,0,0.3),0_2px_4px_rgba(0,0,0,0.3)]
            focus-visible:shadow-[0_0_0_1px_rgba(0,0,0,0.3),0_0_0_3px_rgba(66,153,225,0.6)]
          "
          aria-label="Hue"
        />
      </ColorSliderTrack>
    </ColorSliderRoot>

    <ColorSliderRoot
      v-model="color"
      color-space="hsv"
      channel="alpha"
      as="div"
      class="w-full"
    >
      <ColorSliderTrack
        as="div"
        class="relative h-4 overflow-hidden rounded-full"
      >
        <ColorSliderCheckerboard
          as="div"
          class="absolute inset-0 rounded-full"
        />
        <ColorSliderGradient
          as="div"
          class="absolute inset-0 rounded-full"
        />
        <ColorSliderThumb
          class="
            block size-4 rounded-full border-2 border-white
            shadow-[0_0_0_1px_rgba(0,0,0,0.3),0_2px_4px_rgba(0,0,0,0.3)]
            focus-visible:shadow-[0_0_0_1px_rgba(0,0,0,0.3),0_0_0_3px_rgba(66,153,225,0.6)]
          "
          aria-label="Alpha"
        />
      </ColorSliderTrack>
    </ColorSliderRoot>

    <div class="flex flex-1 flex-wrap gap-2">
      <div
        v-for="ch in channels"
        :key="ch.key"
        class="flex min-w-[60px] flex-1 flex-col gap-1"
      >
        <Label
          :for="`material-field-${ch.key}`"
          class="text-xs font-semibold text-(--vp-c-text-2)"
        >{{ ch.label }}</Label>
        <ColorFieldRoot
          v-model="color"
          color-space="hsl"
          :channel="ch.key"
          class="
            flex items-center overflow-hidden rounded-md border
            border-(--vp-c-divider) bg-(--vp-c-bg)
          "
        >
          <ColorFieldDecrement
            class="
              flex size-7 shrink-0 cursor-pointer items-center justify-center
              border-none bg-transparent text-lg leading-none text-(--vp-c-text-2)
              select-none
              hover:not-disabled:bg-(--vp-c-bg-soft)
              hover:not-disabled:text-(--vp-c-text-1)
              disabled:cursor-default disabled:opacity-30
            "
          >
            &minus;
          </ColorFieldDecrement>
          <ColorFieldInput
            :id="`material-field-${ch.key}`"
            class="
              w-0 min-w-0 flex-1 border-none bg-transparent px-0.5 py-1
              text-center font-mono text-[13px] text-(--vp-c-text-1) outline-none
            "
          />
          <ColorFieldIncrement
            class="
              flex size-7 shrink-0 cursor-pointer items-center justify-center
              border-none bg-transparent text-lg leading-none text-(--vp-c-text-2)
              select-none
              hover:not-disabled:bg-(--vp-c-bg-soft)
              hover:not-disabled:text-(--vp-c-text-1)
              disabled:cursor-default disabled:opacity-30
            "
          >
            +
          </ColorFieldIncrement>
        </ColorFieldRoot>
      </div>
    </div>
  </div>
</template>

Step 1: Set up shared state

All components will share the same color ref.

vue
<script setup lang="ts">
import { useColor } from "@urcolor/vue";  

const { color } = useColor("hsl(210, 80%, 50%)");  
</script>

Step 2: Add the color area

The ColorArea provides a 2D gradient for picking saturation and value in HSV space.

vue
<script setup lang="ts">
import { 
  useColor, 
  ColorAreaRoot, 
  ColorAreaTrack, 
  ColorAreaGradient, 
  ColorAreaThumb, 
  ColorAreaThumbX, 
  ColorAreaThumbY, 
} from "@urcolor/vue"; 

const { color } = useColor("hsl(210, 80%, 50%)");
</script>

<template>
  <div class="flex w-full max-w-xs flex-col gap-3 rounded-xl p-3">
    <ColorAreaRoot
      v-model="color"
      color-space="hsv"
      channel-x="s"
      channel-y="v"
      :inverted-y="true"
    >
      <ColorAreaTrack class="relative h-[180px] w-full cursor-crosshair touch-none overflow-clip rounded-lg">
        <ColorAreaGradient class="absolute inset-0" />
        <ColorAreaThumb
          class="
            absolute size-5 transform-(--reka-slider-area-thumb-transform)
            rounded-full border-2 border-white
            shadow-[0_0_0_1px_rgba(0,0,0,0.3),0_2px_4px_rgba(0,0,0,0.3)]
            focus-visible:shadow-[0_0_0_1px_rgba(0,0,0,0.3),0_0_0_3px_rgba(66,153,225,0.6)]
          "
        >
          <ColorAreaThumbX />
          <ColorAreaThumbY />
        </ColorAreaThumb>
      </ColorAreaTrack>
    </ColorAreaRoot>
  </div>
</template>

Step 3: Add the hue slider

Add a ColorSlider below the area to control the hue channel.

vue
<script setup lang="ts">
import {
  useColor,
  ColorAreaRoot,
  ColorAreaTrack,
  ColorAreaGradient,
  ColorAreaThumb,
  ColorAreaThumbX,
  ColorAreaThumbY,
  ColorSliderRoot, 
  ColorSliderTrack, 
  ColorSliderGradient, 
  ColorSliderThumb, 
} from "@urcolor/vue";

const { color } = useColor("hsl(210, 80%, 50%)");
</script>

<template>
  <div class="flex w-full max-w-xs flex-col gap-3 rounded-xl p-3">
    <ColorAreaRoot
      v-model="color"
      color-space="hsv"
      channel-x="s"
      channel-y="v"
      :inverted-y="true"
    >
      <ColorAreaTrack class="relative h-[180px] w-full cursor-crosshair touch-none overflow-clip rounded-lg">
        <ColorAreaGradient class="absolute inset-0" />
        <ColorAreaThumb
          class="
            absolute size-5 transform-(--reka-slider-area-thumb-transform)
            rounded-full border-2 border-white
            shadow-[0_0_0_1px_rgba(0,0,0,0.3),0_2px_4px_rgba(0,0,0,0.3)]
            focus-visible:shadow-[0_0_0_1px_rgba(0,0,0,0.3),0_0_0_3px_rgba(66,153,225,0.6)]
          "
        >
          <ColorAreaThumbX />
          <ColorAreaThumbY />
        </ColorAreaThumb>
      </ColorAreaTrack>
    </ColorAreaRoot>

    <ColorSliderRoot
      v-model="color"
      color-space="hsv"
      channel="h"
    >
      <ColorSliderTrack class="relative h-4 overflow-hidden rounded-full">
        <ColorSliderGradient
          class="absolute inset-0 rounded-full"
          :colors="['red', 'yellow', 'lime', 'cyan', 'blue', 'magenta', 'red']"
        />
        <ColorSliderThumb
          class="
            block size-4 rounded-full border-2 border-white
            shadow-[0_0_0_1px_rgba(0,0,0,0.3),0_2px_4px_rgba(0,0,0,0.3)]
            focus-visible:shadow-[0_0_0_1px_rgba(0,0,0,0.3),0_0_0_3px_rgba(66,153,225,0.6)]
          "
          aria-label="Hue"
        />
      </ColorSliderTrack>
    </ColorSliderRoot>
  </div>
</template>

Step 4: Add the alpha slider

Add another ColorSlider for the alpha (opacity) channel, with a checkerboard background.

vue
<script setup lang="ts">
import {
  useColor,
  ColorAreaRoot,
  ColorAreaTrack,
  ColorAreaGradient,
  ColorAreaThumb,
  ColorAreaThumbX,
  ColorAreaThumbY,
  ColorSliderRoot,
  ColorSliderTrack,
  ColorSliderGradient,
  ColorSliderThumb,
  ColorSliderCheckerboard, 
} from "@urcolor/vue";

const { color } = useColor("hsl(210, 80%, 50%)");
</script>

<template>
  <div class="flex w-full max-w-xs flex-col gap-3 rounded-xl p-3">
    <!-- ...color area and hue slider... -->

    <ColorSliderRoot
      v-model="color"
      color-space="hsv"
      channel="alpha"
    >
      <ColorSliderTrack class="relative h-4 overflow-hidden rounded-full">
        <ColorSliderCheckerboard class="absolute inset-0 rounded-full" />
        <ColorSliderGradient class="absolute inset-0 rounded-full" />
        <ColorSliderThumb
          class="
            block size-4 rounded-full border-2 border-white
            shadow-[0_0_0_1px_rgba(0,0,0,0.3),0_2px_4px_rgba(0,0,0,0.3)]
            focus-visible:shadow-[0_0_0_1px_rgba(0,0,0,0.3),0_0_0_3px_rgba(66,153,225,0.6)]
          "
          aria-label="Alpha"
        />
      </ColorSliderTrack>
    </ColorSliderRoot>
  </div>
</template>

The ColorSliderCheckerboard renders behind the gradient to indicate transparency.

Step 5: Add color field inputs

Add ColorField inputs for each HSL channel so users can type precise values.

vue
<script setup lang="ts">
import { computed } from "vue"; 
import { colorSpaces } from "@urcolor/core"; 
import { Label } from "reka-ui"; 
import {
  useColor,
  ColorAreaRoot,
  ColorAreaTrack,
  ColorAreaGradient,
  ColorAreaThumb,
  ColorAreaThumbX,
  ColorAreaThumbY,
  ColorSliderRoot,
  ColorSliderTrack,
  ColorSliderGradient,
  ColorSliderThumb,
  ColorSliderCheckerboard,
  ColorFieldRoot, 
  ColorFieldInput, 
  ColorFieldIncrement, 
  ColorFieldDecrement, 
} from "@urcolor/vue";

const { color } = useColor("hsl(210, 80%, 50%)");
const channels = computed(() => colorSpaces["hsl"]?.channels ?? []); 
</script>

<template>
  <div class="flex w-full max-w-xs flex-col gap-3 rounded-xl p-3">
    <!-- ...color area, hue slider, alpha slider... -->

    <div class="flex flex-1 flex-wrap gap-2">
      <div
        v-for="ch in channels"
        :key="ch.key"
        class="flex min-w-[60px] flex-1 flex-col gap-1"
      >
        <Label
          :for="`material-field-${ch.key}`"
          class="text-xs font-semibold"
        >{{ ch.label }}</Label>
        <ColorFieldRoot
          v-model="color"
          color-space="hsl"
          :channel="ch.key"
          class="flex items-center overflow-hidden rounded-md border"
        >
          <ColorFieldDecrement
            class="flex size-7 shrink-0 cursor-pointer items-center justify-center"
          >
            &minus;
          </ColorFieldDecrement>
          <ColorFieldInput
            :id="`material-field-${ch.key}`"
            class="w-0 min-w-0 flex-1 border-none bg-transparent px-0.5 py-1 text-center font-mono text-sm outline-none"
          />
          <ColorFieldIncrement
            class="flex size-7 shrink-0 cursor-pointer items-center justify-center"
          >
            +
          </ColorFieldIncrement>
        </ColorFieldRoot>
      </div>
    </div>
  </div>
</template>

The colorSpaces["hsl"].channels array gives us channel metadata (key and label) so we can dynamically render a field for each HSL channel.

TIP

All components are completely unstyled — the classes above are just an example using Tailwind CSS. You can style the card, inputs, and sliders however you like.