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
<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
"
>
−
</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>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
"
>
−
</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>
);
}<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
"
>
−
</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>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
"
>
−
</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.
<script setup lang="ts">
import { useColor } from "@urcolor/vue";
const { color } = useColor("hsl(210, 80%, 50%)");
</script>import { useColor } from "@urcolor/react";
function MyField() {
const { color, setColor } = useColor("hsl(210, 80%, 50%)");
}<script lang="ts">
import { useColor } from "@urcolor/svelte";
const colorState = useColor("hsl(210, 80%, 50%)");
</script>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.
<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>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>
);
}<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>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.
<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>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>
);
}<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>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.
<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">
−
</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>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">
−
</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>
);
}<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">
−
</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>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">
−
</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:
<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>−</ColorFieldDecrement>
<ColorFieldInput />
<ColorFieldIncrement>+</ColorFieldIncrement>
</ColorFieldRoot>
</div>
</div>
</template>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>−</ColorField.Decrement>
<ColorField.Input />
<ColorField.Increment>+</ColorField.Increment>
</ColorField.Root>
</div>
))}
</div>
);
}<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>−</ColorField.Decrement>
<ColorField.Input />
<ColorField.Increment>+</ColorField.Increment>
</ColorField.Root>
</div>
{/each}
</div>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>−</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:
<template>
<ColorFieldRoot
v-model="color"
color-space="hsl"
channel="hex"
format="hex"
>
<ColorFieldInput />
</ColorFieldRoot>
</template><ColorField.Root
value={color}
onValueChange={setColor}
colorSpace="hsl"
channel="hex"
format="hex"
>
<ColorField.Input />
</ColorField.Root><ColorField.Root
bind:value={() => colorState.color, colorState.setColor}
colorSpace="hsl"
channel="hex"
format="hex"
>
<ColorField.Input />
</ColorField.Root><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.
<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>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><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>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());
}
}