Skip to content

Build Color Fields

Let's build numeric input fields for editing individual color channels step by step.

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,
  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 flex-1 flex-wrap gap-2">
    <div
      v-for="ch in channels"
      :key="ch.key"
      class="flex min-w-[80px] flex-1 flex-col gap-1"
    >
      <Label
        :for="`guide-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-8 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="`guide-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-8 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>
</template>

Step 1: Set up state

Start by importing the color model and creating a reactive color value.

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

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

Step 2: Add the root

ColorFieldRoot manages the state for a single channel input. Tell it which color space and channel to control.

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

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

<template>
  <ColorFieldRoot
    v-model="color"
    color-space="hsl"
    channel="h"
  >
    <!-- children go here -->
  </ColorFieldRoot>
</template>
  • color-space — the color space to work in (hsl, oklch, hsb, etc.)
  • channel — the channel this field controls (h, s, l, etc.)

Step 3: Add the input

ColorFieldInput renders the numeric input. It automatically formats the value based on the channel (degrees, percentages, etc.).

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

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

<template>
  <ColorFieldRoot
    v-model="color"
    color-space="hsl"
    channel="h"
    class="
      flex items-center overflow-hidden rounded-md border
      border-(--vp-c-divider) bg-(--vp-c-bg)
    "
  >
    <ColorFieldInput
      class="
        w-full border-none bg-transparent px-2 py-1
        text-center font-mono text-sm outline-none
      "
    />
  </ColorFieldRoot>
</template>

The input supports keyboard interactions — arrow keys increment and decrement the value, and typing a number updates the color directly.

Step 4: Add increment and decrement buttons

ColorFieldIncrement and ColorFieldDecrement provide stepper buttons for fine-tuning the value.

vue
<script setup lang="ts">
import {
  useColor,
  ColorFieldRoot,
  ColorFieldInput,
  ColorFieldIncrement, 
  ColorFieldDecrement, 
} from "@urcolor/vue";

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

<template>
  <ColorFieldRoot
    v-model="color"
    color-space="hsl"
    channel="h"
    class="
      flex items-center overflow-hidden rounded-md border
      border-(--vp-c-divider) bg-(--vp-c-bg)
    "
  >
    <ColorFieldDecrement class="flex size-8 items-center justify-center">
      &minus;
    </ColorFieldDecrement>
    <ColorFieldInput
      class="
        w-0 min-w-0 flex-1 border-none bg-transparent px-0.5 py-1
        text-center font-mono text-[13px] outline-none
      "
    />
    <ColorFieldIncrement class="flex size-8 items-center justify-center">
      +
    </ColorFieldIncrement>
  </ColorFieldRoot>
</template>

TIP

All components are completely unstyled — the classes above are just an example using Tailwind CSS. Use any styling approach you prefer.

Multiple channels

To build a full channel editor, loop over the channels in a color space using the colorSpaces helper from @urcolor/core:

vue
<script setup lang="ts">
import { computed } from "vue";
import { colorSpaces } from "@urcolor/core";
import {
  useColor,
  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 gap-2">
    <div v-for="ch in channels" :key="ch.key" class="flex flex-col gap-1">
      <label class="text-xs font-semibold">{{ ch.label }}</label>
      <ColorFieldRoot v-model="color" color-space="hsl" :channel="ch.key">
        <ColorFieldDecrement>&minus;</ColorFieldDecrement>
        <ColorFieldInput />
        <ColorFieldIncrement>+</ColorFieldIncrement>
      </ColorFieldRoot>
    </div>
  </div>
</template>

Each ColorFieldRoot shares the same v-model — updating one channel automatically keeps the others in sync.

Hex format

Set channel to "hex" and format to "hex" for a hex color input:

vue
<template>
  <ColorFieldRoot
    v-model="color"
    color-space="hsl"
    channel="hex"
    format="hex"
  >
    <ColorFieldInput />
  </ColorFieldRoot>
</template>

Listening to changes

Use @update:model-value for real-time updates and @value-commit for the final value (on blur or Enter):

vue
<script setup lang="ts">
// ...
const onColorChange = (color: Color) => {
  console.log("changing", color.toString("css"));
};
const onColorCommit = (color: Color) => {
  console.log("committed", color.toString("css"));
};
</script>

<template>
  <ColorFieldRoot
    v-model="color"
    color-space="hsl"
    channel="h"
    @update:model-value="onColorChange"
    @value-commit="onColorCommit"
  >
    <!-- ... -->
  </ColorFieldRoot>
</template>