Skip to content

How to Build Color Fields

Color fields are numeric inputs, one per channel, for typing exact values.

Here's what we'll end up with:

Click to view the full code
vue
<script setup lang="ts">
import { Label } from "reka-ui";
import {
  useColor,
  ColorFieldRoot,
  ColorFieldInput,
  ColorFieldIncrement,
  ColorFieldDecrement,
} from "@urcolor/vue";

const { color, channels } = useColor("hsl(210, 80%, 50%)", "hsl");
</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>
tsx
import { ColorField, useColor } from "@urcolor/react";

export default function ColorFieldGuide() {
  const { color, setColor, channels } = useColor("hsl(210, 80%, 50%)", "hsl");

  return (
    <div className="flex flex-1 flex-wrap gap-2">
      {channels.map((ch) => (
        <div key={ch.key} className="flex min-w-[80px] flex-1 flex-col gap-1">
          <label
            htmlFor={`guide-field-${ch.key}`}
            className="text-xs font-semibold text-[var(--vp-c-text-2)]"
          >
            {ch.label}
          </label>
          <ColorField.Root
            value={color}
            onValueChange={setColor}
            colorSpace="hsl"
            channel={ch.key}
            className="
              flex items-center overflow-hidden rounded-md border
              border-[var(--vp-c-divider)] bg-[var(--vp-c-bg)]
            "
          >
            <ColorField.Decrement
              className="
                flex size-8 shrink-0 cursor-pointer items-center justify-center
                border-none bg-transparent text-lg leading-none text-[var(--vp-c-text-2)]
                select-none
                hover:not-disabled:bg-[var(--vp-c-bg-soft)]
                hover:not-disabled:text-[var(--vp-c-text-1)]
                disabled:cursor-default disabled:opacity-30
              "
            >
              &minus;
            </ColorField.Decrement>
            <ColorField.Input
              id={`guide-field-${ch.key}`}
              className="
                w-0 min-w-0 flex-1 border-none bg-transparent px-0.5 py-1
                text-center font-mono text-[13px] text-[var(--vp-c-text-1)] outline-none
              "
            />
            <ColorField.Increment
              className="
                flex size-8 shrink-0 cursor-pointer items-center justify-center
                border-none bg-transparent text-lg leading-none text-[var(--vp-c-text-2)]
                select-none
                hover:not-disabled:bg-[var(--vp-c-bg-soft)]
                hover:not-disabled:text-[var(--vp-c-text-1)]
                disabled:cursor-default disabled:opacity-30
              "
            >
              +
            </ColorField.Increment>
          </ColorField.Root>
        </div>
      ))}
    </div>
  );
}
svelte
<script lang="ts">
  import { ColorField, useColor } from "@urcolor/svelte";

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

<div class="flex flex-1 flex-wrap gap-2">
  {#each colorState.channels as ch (ch.key)}
    <div 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>
      <ColorField.Root
        bind:value={() => colorState.color, colorState.setColor}
        colorSpace="hsl"
        channel={ch.key}
        class="
          flex items-center overflow-hidden rounded-md border
          border-(--vp-c-divider) bg-(--vp-c-bg)
        "
      >
        <ColorField.Decrement
          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;
        </ColorField.Decrement>
        <ColorField.Input
          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
          "
        />
        <ColorField.Increment
          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
          "
        >
          +
        </ColorField.Increment>
      </ColorField.Root>
    </div>
  {/each}
</div>
ts
import { Component, signal } from "@angular/core";
import { Color } from "@urcolor/core";
import { channelsOf } from "@urcolor/shared";
import { COLOR_FIELD_DIRECTIVES } from "@urcolor/angular";

@Component({
  selector: "color-field-guide",
  imports: [...COLOR_FIELD_DIRECTIVES],
  template: `
    <div class="flex flex-1 flex-wrap gap-2">
      @for (ch of channels; track ch.key) {
        <div class="flex min-w-[80px] flex-1 flex-col gap-1">
          <label
            [attr.for]="'guide-field-' + ch.key"
            class="text-xs font-semibold text-[var(--vp-c-text-2)]"
          >{{ ch.label }}</label>
          <div
            urcColorFieldRoot
            [(value)]="color"
            colorSpace="hsl"
            [channel]="ch.key"
            class="
              flex items-center overflow-hidden rounded-md border
              border-[var(--vp-c-divider)] bg-[var(--vp-c-bg)]
            "
          >
            <button
              type="button"
              urcColorFieldDecrement
              class="
                flex size-8 shrink-0 cursor-pointer items-center justify-center
                border-none bg-transparent text-lg leading-none text-[var(--vp-c-text-2)]
                select-none
                hover:not-disabled:bg-[var(--vp-c-bg-soft)]
                hover:not-disabled:text-[var(--vp-c-text-1)]
                disabled:cursor-default disabled:opacity-30
              "
            >
              &minus;
            </button>
            <input
              urcColorFieldInput
              [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-[var(--vp-c-text-1)] outline-none
              "
            />
            <button
              type="button"
              urcColorFieldIncrement
              class="
                flex size-8 shrink-0 cursor-pointer items-center justify-center
                border-none bg-transparent text-lg leading-none text-[var(--vp-c-text-2)]
                select-none
                hover:not-disabled:bg-[var(--vp-c-bg-soft)]
                hover:not-disabled:text-[var(--vp-c-text-1)]
                disabled:cursor-default disabled:opacity-30
              "
            >
              +
            </button>
          </div>
        </div>
      }
    </div>
  `,
})
export class ColorFieldGuide {
  protected readonly color = signal<Color>(Color.parse("hsl(210, 80%, 50%)")!);
  protected readonly channels = channelsOf("hsl");
}

The parts, and how they nest:

Step 1: Set up state

Import the color model and create the color state.

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

const { color } = useColor("hsl(210, 80%, 50%)");  
</script>
tsx
import { useColor } from "@urcolor/react"; 

function MyField() {
  const { color, setColor } = useColor("hsl(210, 80%, 50%)"); 
}
svelte
<script lang="ts">
  import { useColor } from "@urcolor/svelte"; 

  const colorState = useColor("hsl(210, 80%, 50%)"); 
</script>
ts
import { Component, signal } from "@angular/core";
import { Color } from "@urcolor/core"; 

@Component({
  selector: "my-field",
  template: ``,
})
export class MyField {
  protected readonly color = signal<Color>(Color.parse("hsl(210, 80%, 50%)")!); 
}

useColor() creates color state from any CSS color string. Vue returns a { color } shallow ref and React returns { color, setColor }. Svelte returns a rune-backed object whose color, hex and alpha are getters, so keep the object and read colorState.color rather than destructuring it, or reactivity is lost. Angular has no hook: a plain signal<Color>() is the state, and [(value)] binds to it directly.

Step 2: Add the root

The root manages the state for a single channel input. Tell it which color space to work in and which 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>
tsx
import { useColor, ColorField } from "@urcolor/react"; 

function MyField() {
  const { color, setColor } = useColor("hsl(210, 80%, 50%)");

  return (
    <ColorField.Root
      value={color}
      onValueChange={setColor}
      colorSpace="hsl"
      channel="h"
    >
      {/* children go here */}
    </ColorField.Root>
  );
}
svelte
<script lang="ts">
  import { ColorField, useColor } from "@urcolor/svelte"; 

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

<ColorField.Root
  bind:value={() => colorState.color, colorState.setColor}
  colorSpace="hsl"
  channel="h"
>
  <!-- children go here -->
</ColorField.Root>
ts
import { Component, signal } from "@angular/core";
import { Color } from "@urcolor/core";
import { COLOR_FIELD_DIRECTIVES } from "@urcolor/angular"; 

@Component({
  selector: "my-field",
  imports: [...COLOR_FIELD_DIRECTIVES], 
  template: `
    <div
      urcColorFieldRoot
      [(value)]="color"
      colorSpace="hsl"
      channel="h"
    >
      <!-- children go here -->
    </div>
  `,
})
export class MyField {
  protected readonly color = signal<Color>(Color.parse("hsl(210, 80%, 50%)")!);
}

Vue's v-model and Angular's [(value)] are true two-way bindings. React is one-way plus onValueChange. Svelte's value is $bindable, but useColor exposes getters, so bind it with Svelte 5's function form, bind:value={() => colorState.color, colorState.setColor}, which is v-model for a getter/setter pair.

Angular ships each family as a COLOR_*_DIRECTIVES array, so one entry in imports brings in the whole set.

  • color-space / colorSpace: the color space to work in (hsl, oklch, hsv, etc.)
  • channel: the channel this field controls (h, s, l, etc.)

Step 3: Add the input

The input renders the numeric field and formats the value for its channel, in degrees or percentages as appropriate.

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>
tsx
import { useColor, ColorField } from "@urcolor/react";

function MyField() {
  const { color, setColor } = useColor("hsl(210, 80%, 50%)");

  return (
    <ColorField.Root
      value={color}
      onValueChange={setColor}
      colorSpace="hsl"
      channel="h"
      className="
        flex items-center overflow-hidden rounded-md border
        border-[var(--vp-c-divider)] bg-[var(--vp-c-bg)]
      "
    >
      <ColorField.Input
        className="
          w-full border-none bg-transparent px-2 py-1
          text-center font-mono text-sm outline-none
        "
      />
    </ColorField.Root>
  );
}
svelte
<script lang="ts">
  import { ColorField, useColor } from "@urcolor/svelte";

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

<ColorField.Root
  bind:value={() => colorState.color, colorState.setColor}
  colorSpace="hsl"
  channel="h"
  class="
    flex items-center overflow-hidden rounded-md border
    border-(--vp-c-divider) bg-(--vp-c-bg)
  "
>
  <ColorField.Input
    class="
      w-full border-none bg-transparent px-2 py-1
      text-center font-mono text-sm outline-none
    "
  />
</ColorField.Root>
ts
import { Component, signal } from "@angular/core";
import { Color } from "@urcolor/core";
import { COLOR_FIELD_DIRECTIVES } from "@urcolor/angular";

@Component({
  selector: "my-field",
  imports: [...COLOR_FIELD_DIRECTIVES],
  template: `
    <div
      urcColorFieldRoot
      [(value)]="color"
      colorSpace="hsl"
      channel="h"
      class="
        flex items-center overflow-hidden rounded-md border
        border-[var(--vp-c-divider)] bg-[var(--vp-c-bg)]
      "
    >
      <input
        urcColorFieldInput
        class="
          w-full border-none bg-transparent px-2 py-1
          text-center font-mono text-sm outline-none
        "
      />
    </div>
  `,
})
export class MyField {
  protected readonly color = signal<Color>(Color.parse("hsl(210, 80%, 50%)")!);
}

In Angular the input's selector is input[urcColorFieldInput], so the directive goes on an <input> element you own; the other three render their own input for you.

The input takes keyboard input: arrow keys step the value, and typing a number updates the color directly.

Step 4: Add increment and decrement buttons

The increment and decrement parts are stepper buttons for fine adjustment.

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>
tsx
import { useColor, ColorField } from "@urcolor/react";

function MyField() {
  const { color, setColor } = useColor("hsl(210, 80%, 50%)");

  return (
    <ColorField.Root
      value={color}
      onValueChange={setColor}
      colorSpace="hsl"
      channel="h"
      className="
        flex items-center overflow-hidden rounded-md border
        border-[var(--vp-c-divider)] bg-[var(--vp-c-bg)]
      "
    >
      <ColorField.Decrement className="flex size-8 items-center justify-center">
        &minus;
      </ColorField.Decrement>
      <ColorField.Input
        className="
          w-0 min-w-0 flex-1 border-none bg-transparent px-0.5 py-1
          text-center font-mono text-[13px] outline-none
        "
      />
      <ColorField.Increment className="flex size-8 items-center justify-center">
        +
      </ColorField.Increment>
    </ColorField.Root>
  );
}
svelte
<script lang="ts">
  import { ColorField, useColor } from "@urcolor/svelte";

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

<ColorField.Root
  bind:value={() => colorState.color, colorState.setColor}
  colorSpace="hsl"
  channel="h"
  class="
    flex items-center overflow-hidden rounded-md border
    border-(--vp-c-divider) bg-(--vp-c-bg)
  "
>
  <ColorField.Decrement class="flex size-8 items-center justify-center">
    &minus;
  </ColorField.Decrement>
  <ColorField.Input
    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
    "
  />
  <ColorField.Increment class="flex size-8 items-center justify-center">
    +
  </ColorField.Increment>
</ColorField.Root>
ts
import { Component, signal } from "@angular/core";
import { Color } from "@urcolor/core";
import { COLOR_FIELD_DIRECTIVES } from "@urcolor/angular";

@Component({
  selector: "my-field",
  imports: [...COLOR_FIELD_DIRECTIVES],
  template: `
    <div
      urcColorFieldRoot
      [(value)]="color"
      colorSpace="hsl"
      channel="h"
      class="
        flex items-center overflow-hidden rounded-md border
        border-[var(--vp-c-divider)] bg-[var(--vp-c-bg)]
      "
    >
      <button type="button" urcColorFieldDecrement class="flex size-8 items-center justify-center">
        &minus;
      </button>
      <input
        urcColorFieldInput
        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
        "
      />
      <button type="button" urcColorFieldIncrement class="flex size-8 items-center justify-center">
        +
      </button>
    </div>
  `,
})
export class MyField {
  protected readonly color = signal<Color>(Color.parse("hsl(210, 80%, 50%)")!);
}

Angular scopes the steppers to buttons. The selectors are button[urcColorFieldIncrement] and button[urcColorFieldDecrement], so they go on <button> elements you own, and the directive drives the press-and-hold repeat and the disabled state.

TIP

The components ship unstyled. The classes above are one example, written with Tailwind CSS; any styling approach works.

Multiple channels

A full channel editor loops over the channels useColor already exposes. The second argument pins them to a space, so a control working in another space cannot renumber the fields underneath:

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

const { color, channels } = useColor("hsl(210, 80%, 50%)");
</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>
tsx
import { useColor, ColorField } from "@urcolor/react";

function MyFields() {
  const { color, setColor, channels } = useColor("hsl(210, 80%, 50%)", "hsl");

  return (
    <div className="flex gap-2">
      {channels.map((ch) => (
        <div key={ch.key} className="flex flex-col gap-1">
          <label className="text-xs font-semibold">{ch.label}</label>
          <ColorField.Root value={color} onValueChange={setColor} colorSpace="hsl" channel={ch.key}>
            <ColorField.Decrement>&minus;</ColorField.Decrement>
            <ColorField.Input />
            <ColorField.Increment>+</ColorField.Increment>
          </ColorField.Root>
        </div>
      ))}
    </div>
  );
}
svelte
<script lang="ts">
  import { ColorField, useColor } from "@urcolor/svelte";

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

<div class="flex gap-2">
  {#each colorState.channels as ch (ch.key)}
    <div class="flex flex-col gap-1">
      <label class="text-xs font-semibold">{ch.label}</label>
      <ColorField.Root
        bind:value={() => colorState.color, colorState.setColor}
        colorSpace="hsl"
        channel={ch.key}
      >
        <ColorField.Decrement>&minus;</ColorField.Decrement>
        <ColorField.Input />
        <ColorField.Increment>+</ColorField.Increment>
      </ColorField.Root>
    </div>
  {/each}
</div>
ts
import { Component, signal } from "@angular/core";
import { Color } from "@urcolor/core";
import { channelsOf } from "@urcolor/shared";
import { COLOR_FIELD_DIRECTIVES } from "@urcolor/angular";

@Component({
  selector: "my-fields",
  imports: [...COLOR_FIELD_DIRECTIVES],
  template: `
    <div class="flex gap-2">
      @for (ch of channels; track ch.key) {
        <div class="flex flex-col gap-1">
          <label class="text-xs font-semibold">{{ ch.label }}</label>
          <div urcColorFieldRoot [(value)]="color" colorSpace="hsl" [channel]="ch.key">
            <button type="button" urcColorFieldDecrement>&minus;</button>
            <input urcColorFieldInput />
            <button type="button" urcColorFieldIncrement>+</button>
          </div>
        </div>
      }
    </div>
  `,
})
export class MyFields {
  protected readonly color = signal<Color>(Color.parse("hsl(210, 80%, 50%)")!);
  protected readonly channels = channelsOf("hsl");
}

Angular has no useColor, so its component reads the same list from channelsOf in @urcolor/shared. createColorStore exposes it as a channels signal for anyone already using the store.

Svelte loops with {#each} and Angular with @for. The channel value is dynamic in every framework, so it takes a binding: :channel in Vue, channel={ch.key} in React and Svelte, [channel] in Angular.

Every field shares one color state, so updating one channel keeps the others in sync.

Hex format

channel and format both set to "hex" give a hex input:

vue
<template>
  <ColorFieldRoot
    v-model="color"
    color-space="hsl"
    channel="hex"
    format="hex"
  >
    <ColorFieldInput />
  </ColorFieldRoot>
</template>
tsx
<ColorField.Root
  value={color}
  onValueChange={setColor}
  colorSpace="hsl"
  channel="hex"
  format="hex"
>
  <ColorField.Input />
</ColorField.Root>
svelte
<ColorField.Root
  bind:value={() => colorState.color, colorState.setColor}
  colorSpace="hsl"
  channel="hex"
  format="hex"
>
  <ColorField.Input />
</ColorField.Root>
html
<div
  urcColorFieldRoot
  [(value)]="color"
  colorSpace="hsl"
  channel="hex"
  format="hex"
>
  <input urcColorFieldInput />
</div>

Listening to changes

Vue fires @update:model-value on every keystroke that parses and @change-end for the settled value on blur, Enter, an arrow key, the wheel or a stepper button. React and Svelte call onValueChange and onValueCommit respectively; Angular emits (valueChange) and (valueCommit). Angular's (valueChange) is the output half of [(value)], so when you listen to it explicitly you bind the input one-way as [value]="color()" and write the signal yourself.

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

<template>
  <ColorFieldRoot
    v-model="color"
    color-space="hsl"
    channel="h"
    @update:model-value="onColorChange"
    @change-end="onColorChangeEnd"
  >
    <!-- ... -->
  </ColorFieldRoot>
</template>
tsx
import { Color } from "@urcolor/core";

const onColorChange = (color: Color) => {
  console.log("changing", color.toString());
};
const onColorCommit = (color: Color) => {
  console.log("committed", color.toString());
};

<ColorField.Root
  value={color}
  onValueChange={onColorChange}
  onValueCommit={onColorCommit}
  colorSpace="hsl"
  channel="h"
>
  {/* ... */}
</ColorField.Root>
svelte
<script lang="ts">
  import type { Color } from "@urcolor/core";
  // ...
  const onColorChange = (color: Color) => {
    console.log("changing", color.toString());
  };
  const onColorCommit = (color: Color) => {
    console.log("committed", color.toString());
  };
</script>

<ColorField.Root
  bind:value={() => colorState.color, colorState.setColor}
  colorSpace="hsl"
  channel="h"
  onValueChange={onColorChange}
  onValueCommit={onColorCommit}
>
  <!-- ... -->
</ColorField.Root>
ts
import { Component, signal } from "@angular/core";
import { Color } from "@urcolor/core";
import { COLOR_FIELD_DIRECTIVES } from "@urcolor/angular";

@Component({
  selector: "my-field",
  imports: [...COLOR_FIELD_DIRECTIVES],
  template: `
    <div
      urcColorFieldRoot
      [value]="color()"
      (valueChange)="onColorChange($event)"
      (valueCommit)="onColorCommit($event)"
      colorSpace="hsl"
      channel="h"
    >
      <!-- ... -->
    </div>
  `,
})
export class MyField {
  protected readonly color = signal<Color>(Color.parse("hsl(210, 80%, 50%)")!);

  protected onColorChange(color: Color): void {
    this.color.set(color);
    console.log("changing", color.toString());
  }

  protected onColorCommit(color: Color): void {
    console.log("committed", color.toString());
  }
}