How to Build a Color Area Picker
A color area maps two channels onto a plane, so one drag sets both at once.
Here's what we'll end up with:
Click to view the full code
<script setup lang="ts">
import {
useColor,
ColorAreaRoot,
ColorAreaArea,
ColorAreaGradient,
ColorAreaThumb,
} from "@urcolor/vue";
const { color } = useColor("hsl(210, 80%, 50%)");
</script>
<template>
<ColorAreaRoot
v-model="color"
color-space="hsl"
x-channel="h"
y-channel="s"
as="div"
class="
relative block h-[200px] w-full cursor-crosshair touch-none overflow-clip
rounded-lg
"
>
<ColorAreaArea as="div" class="absolute inset-0">
<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)]
"
/>
</ColorAreaArea>
</ColorAreaRoot>
</template>import { ColorArea, useColor } from "@urcolor/react";
export default function ColorAreaGuide() {
const { color, setColor } = useColor("hsl(210, 80%, 50%)");
return (
<ColorArea.Root
value={color}
onValueChange={setColor}
colorSpace="hsl"
xChannel="h"
yChannel="s"
className="
relative block h-[200px] w-full cursor-crosshair touch-none
overflow-clip rounded-lg
"
>
<ColorArea.Gradient className="absolute inset-0" />
<ColorArea.Thumb
className="
absolute size-5
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)]
"
/>
</ColorArea.Root>
);
}<script lang="ts">
import { ColorArea, useColor } from "@urcolor/svelte";
const colorState = useColor("hsl(210, 80%, 50%)");
</script>
<ColorArea.Root
bind:value={() => colorState.color, colorState.setColor}
colorSpace="hsl"
xChannel="h"
yChannel="s"
class="
relative block h-[200px] w-full cursor-crosshair touch-none overflow-clip
rounded-lg
"
>
<ColorArea.Gradient class="absolute inset-0" />
<ColorArea.Thumb
class="
absolute size-5
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)]
"
/>
</ColorArea.Root>import { Component, signal } from "@angular/core";
import { Color } from "@urcolor/core";
import { COLOR_AREA_DIRECTIVES } from "@urcolor/angular";
@Component({
selector: "color-area-guide",
imports: [...COLOR_AREA_DIRECTIVES],
template: `
<div
urcColorAreaRoot
[(value)]="color"
colorSpace="hsl"
xChannel="h"
yChannel="s"
class="
relative block h-[200px] w-full cursor-crosshair touch-none overflow-clip
rounded-lg
"
>
<canvas urcColorAreaGradient class="absolute inset-0"></canvas>
<div
urcColorAreaThumb
class="
absolute size-5
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)]
"
></div>
</div>
`,
})
export class ColorAreaGuide {
protected readonly color = signal<Color>(Color.parse("hsl(210, 80%, 50%)")!);
}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 MyArea() {
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-area",
template: ``,
})
export class MyArea {
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 owns the state and the interactions. Tell it which color space to work in and which channel goes on each axis.
<script setup lang="ts">
import { useColor, ColorAreaRoot } from "@urcolor/vue";
const { color } = useColor("hsl(210, 80%, 50%)");
</script>
<template>
<ColorAreaRoot
v-model="color"
color-space="hsl"
x-channel="h"
y-channel="s"
as="div"
>
<!-- children go here -->
</ColorAreaRoot>
</template>import { useColor, ColorArea } from "@urcolor/react";
function MyArea() {
const { color, setColor } = useColor("hsl(210, 80%, 50%)");
return (
<ColorArea.Root
value={color}
onValueChange={setColor}
colorSpace="hsl"
xChannel="h"
yChannel="s"
>
{/* children go here */}
</ColorArea.Root>
);
}<script lang="ts">
import { ColorArea, useColor } from "@urcolor/svelte";
const colorState = useColor("hsl(210, 80%, 50%)");
</script>
<ColorArea.Root
bind:value={() => colorState.color, colorState.setColor}
colorSpace="hsl"
xChannel="h"
yChannel="s"
>
<!-- children go here -->
</ColorArea.Root>import { Component, signal } from "@angular/core";
import { Color } from "@urcolor/core";
import { COLOR_AREA_DIRECTIVES } from "@urcolor/angular";
@Component({
selector: "my-area",
imports: [...COLOR_AREA_DIRECTIVES],
template: `
<div
urcColorAreaRoot
[(value)]="color"
colorSpace="hsl"
xChannel="h"
yChannel="s"
>
<!-- children go here -->
</div>
`,
})
export class MyArea {
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.)x-channel/xChannel: the channel mapped to the horizontal axisy-channel/yChannel: the channel mapped to the vertical axis
Step 3: Add the interaction surface
In Vue, ColorAreaArea is the interaction surface: the root owns the state and the value maths, but every pointer and keyboard listener lives here, and this is the element the pointer coordinates are measured against. Everything else goes inside it. React has no separate element; its root is the interaction surface, so the sizing classes go straight on the root. Svelte and Angular behave like React — neither package ships an Area part, so their roots take the sizing classes too.
<script setup lang="ts">
import {
useColor,
ColorAreaRoot,
ColorAreaArea,
} from "@urcolor/vue";
const { color } = useColor("hsl(210, 80%, 50%)");
</script>
<template>
<ColorAreaRoot
v-model="color"
color-space="hsl"
x-channel="h"
y-channel="s"
as="div"
class="
relative block h-[200px] w-full cursor-crosshair
touch-none overflow-clip rounded-lg
"
>
<ColorAreaArea as="div" class="absolute inset-0">
<!-- gradient and thumb go here -->
</ColorAreaArea>
</ColorAreaRoot>
</template>import { useColor, ColorArea } from "@urcolor/react";
function MyArea() {
const { color, setColor } = useColor("hsl(210, 80%, 50%)");
return (
<ColorArea.Root
value={color}
onValueChange={setColor}
colorSpace="hsl"
xChannel="h"
yChannel="s"
className="
relative h-[200px] w-full cursor-crosshair
touch-none overflow-clip rounded-lg
"
>
{/* gradient and thumb go here */}
</ColorArea.Root>
);
}<script lang="ts">
import { ColorArea, useColor } from "@urcolor/svelte";
const colorState = useColor("hsl(210, 80%, 50%)");
</script>
<ColorArea.Root
bind:value={() => colorState.color, colorState.setColor}
colorSpace="hsl"
xChannel="h"
yChannel="s"
class="
relative block h-[200px] w-full cursor-crosshair
touch-none overflow-clip rounded-lg
"
>
<!-- gradient and thumb go here -->
</ColorArea.Root>import { Component, signal } from "@angular/core";
import { Color } from "@urcolor/core";
import { COLOR_AREA_DIRECTIVES } from "@urcolor/angular";
@Component({
selector: "my-area",
imports: [...COLOR_AREA_DIRECTIVES],
template: `
<div
urcColorAreaRoot
[(value)]="color"
colorSpace="hsl"
xChannel="h"
yChannel="s"
class="
relative block h-[200px] w-full cursor-crosshair
touch-none overflow-clip rounded-lg
"
>
<!-- gradient and thumb go here -->
</div>
`,
})
export class MyArea {
protected readonly color = signal<Color>(Color.parse("hsl(210, 80%, 50%)")!);
}The picker needs a fixed height and position: relative so the thumb can be placed inside it. touch-none keeps a drag from scrolling the page on mobile.
Vue only
Without ColorAreaArea the picker still renders, but it will not respond to clicks, drags or arrow keys, because the root attaches no handlers of its own.
Step 4: Add the gradient
The gradient part paints the plane on a canvas.
<script setup lang="ts">
import {
useColor,
ColorAreaRoot,
ColorAreaArea,
ColorAreaGradient,
} from "@urcolor/vue";
const { color } = useColor("hsl(210, 80%, 50%)");
</script>
<template>
<ColorAreaRoot
v-model="color"
color-space="hsl"
x-channel="h"
y-channel="s"
as="div"
class="
relative block h-[200px] w-full cursor-crosshair
touch-none overflow-clip rounded-lg
"
>
<ColorAreaArea as="div" class="absolute inset-0">
<ColorAreaGradient as="div" class="absolute inset-0" />
</ColorAreaArea>
</ColorAreaRoot>
</template>import { useColor, ColorArea } from "@urcolor/react";
function MyArea() {
const { color, setColor } = useColor("hsl(210, 80%, 50%)");
return (
<ColorArea.Root
value={color}
onValueChange={setColor}
colorSpace="hsl"
xChannel="h"
yChannel="s"
className="
relative h-[200px] w-full cursor-crosshair
touch-none overflow-clip rounded-lg
"
>
<ColorArea.Gradient className="absolute inset-0" /> {}
</ColorArea.Root>
);
}<script lang="ts">
import { ColorArea, useColor } from "@urcolor/svelte";
const colorState = useColor("hsl(210, 80%, 50%)");
</script>
<ColorArea.Root
bind:value={() => colorState.color, colorState.setColor}
colorSpace="hsl"
xChannel="h"
yChannel="s"
class="
relative block h-[200px] w-full cursor-crosshair
touch-none overflow-clip rounded-lg
"
>
<ColorArea.Gradient class="absolute inset-0" />
</ColorArea.Root>import { Component, signal } from "@angular/core";
import { Color } from "@urcolor/core";
import { COLOR_AREA_DIRECTIVES } from "@urcolor/angular";
@Component({
selector: "my-area",
imports: [...COLOR_AREA_DIRECTIVES],
template: `
<div
urcColorAreaRoot
[(value)]="color"
colorSpace="hsl"
xChannel="h"
yChannel="s"
class="
relative block h-[200px] w-full cursor-crosshair
touch-none overflow-clip rounded-lg
"
>
<canvas urcColorAreaGradient class="absolute inset-0"></canvas>
</div>
`,
})
export class MyArea {
protected readonly color = signal<Color>(Color.parse("hsl(210, 80%, 50%)")!);
}In Angular the gradient's selector is canvas[urcColorAreaGradient], so it goes on a <canvas> element you own; the other three render their own canvas for you. None of the four ships a separate Checkerboard part: the gradient paints the transparency checkerboard behind itself.
Step 5: Add the thumb
The thumb is the visible, styled handle. It is the picker's single focusable element and carries role="slider" for screen readers.
<script setup lang="ts">
import {
useColor,
ColorAreaRoot,
ColorAreaArea,
ColorAreaGradient,
ColorAreaThumb,
} from "@urcolor/vue";
const { color } = useColor("hsl(210, 80%, 50%)");
</script>
<template>
<ColorAreaRoot
v-model="color"
color-space="hsl"
x-channel="h"
y-channel="s"
as="div"
class="
relative block h-[200px] w-full cursor-crosshair
touch-none overflow-clip rounded-lg
"
>
<ColorAreaArea as="div" class="absolute inset-0">
<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)]
"
/>
</ColorAreaArea>
</ColorAreaRoot>
</template>import { useColor, ColorArea } from "@urcolor/react";
function MyArea() {
const { color, setColor } = useColor("hsl(210, 80%, 50%)");
return (
<ColorArea.Root
value={color}
onValueChange={setColor}
colorSpace="hsl"
xChannel="h"
yChannel="s"
className="
relative h-[200px] w-full cursor-crosshair
touch-none overflow-clip rounded-lg
"
>
<ColorArea.Gradient className="absolute inset-0" />
<ColorArea.Thumb
className="
absolute size-5
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)]
"
/>
</ColorArea.Root>
);
}<script lang="ts">
import { ColorArea, useColor } from "@urcolor/svelte";
const colorState = useColor("hsl(210, 80%, 50%)");
</script>
<ColorArea.Root
bind:value={() => colorState.color, colorState.setColor}
colorSpace="hsl"
xChannel="h"
yChannel="s"
class="
relative block h-[200px] w-full cursor-crosshair
touch-none overflow-clip rounded-lg
"
>
<ColorArea.Gradient class="absolute inset-0" />
<ColorArea.Thumb
class="
absolute size-5
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)]
"
/>
</ColorArea.Root>import { Component, signal } from "@angular/core";
import { Color } from "@urcolor/core";
import { COLOR_AREA_DIRECTIVES } from "@urcolor/angular";
@Component({
selector: "my-area",
imports: [...COLOR_AREA_DIRECTIVES],
template: `
<div
urcColorAreaRoot
[(value)]="color"
colorSpace="hsl"
xChannel="h"
yChannel="s"
class="
relative block h-[200px] w-full cursor-crosshair
touch-none overflow-clip rounded-lg
"
>
<canvas urcColorAreaGradient class="absolute inset-0"></canvas>
<div
urcColorAreaThumb
class="
absolute size-5
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)]
"
></div>
</div>
`,
})
export class MyArea {
protected readonly color = signal<Color>(Color.parse("hsl(210, 80%, 50%)")!);
}In Vue, transform-(--reka-slider-area-thumb-transform) applies a CSS variable the component sets to position the thumb at the correct coordinates; React's thumb positions itself. Svelte and Angular are like React: their thumbs read that same variable from the root's own style, so you only need the visual classes.
TIP
The components ship unstyled. The classes above are one example, written with Tailwind CSS; any styling approach works.
Switching color spaces
Changing the color space and the channel mapping changes what the picker does. Switching from HSL to OKLCh:
<script setup lang="ts">
import { useColor } from "@urcolor/vue";
const { color } = useColor("oklch(0.6 0.15 210)");
</script>
<template>
<ColorAreaRoot
v-model="color"
color-space="oklch"
x-channel="c"
y-channel="l"
>
<!-- ... -->
</ColorAreaRoot>
</template>const { color, setColor } = useColor("oklch(0.6 0.15 210)");
<ColorArea.Root
value={color}
onValueChange={setColor}
colorSpace="oklch"
xChannel="c"
yChannel="l"
>
{/* ... */}
</ColorArea.Root><script lang="ts">
import { useColor } from "@urcolor/svelte";
const colorState = useColor("oklch(0.6 0.15 210)");
</script>
<ColorArea.Root
bind:value={() => colorState.color, colorState.setColor}
colorSpace="oklch"
xChannel="c"
yChannel="l"
>
<!-- ... -->
</ColorArea.Root>import { Component, signal } from "@angular/core";
import { Color } from "@urcolor/core";
import { COLOR_AREA_DIRECTIVES } from "@urcolor/angular";
@Component({
selector: "my-area",
imports: [...COLOR_AREA_DIRECTIVES],
template: `
<div
urcColorAreaRoot
[(value)]="color"
colorSpace="oklch"
xChannel="c"
yChannel="l"
>
<!-- ... -->
</div>
`,
})
export class MyArea {
protected readonly color = signal<Color>(Color.parse("oklch(0.6 0.15 210)")!);
}Mapping different HSL channels gives a saturation × lightness picker:
<template>
<ColorAreaRoot
v-model="color"
color-space="hsl"
x-channel="s"
y-channel="l"
>
<!-- ... -->
</ColorAreaRoot>
</template><ColorArea.Root
value={color}
onValueChange={setColor}
colorSpace="hsl"
xChannel="s"
yChannel="l"
>
{/* ... */}
</ColorArea.Root><ColorArea.Root
bind:value={() => colorState.color, colorState.setColor}
colorSpace="hsl"
xChannel="s"
yChannel="l"
>
<!-- ... -->
</ColorArea.Root><div
urcColorAreaRoot
[(value)]="color"
colorSpace="hsl"
xChannel="s"
yChannel="l"
>
<!-- ... -->
</div>Inverting axis direction
Reversing an axis maps x from right to left, or y from bottom to top.
<template>
<ColorAreaRoot
v-model="color"
color-space="hsl"
x-channel="h"
y-channel="l"
:x-inverted="true"
:y-inverted="true"
>
<!-- ... -->
</ColorAreaRoot>
</template><ColorArea.Root
value={color}
onValueChange={setColor}
colorSpace="hsl"
xChannel="h"
yChannel="l"
xInverted
yInverted
>
{/* ... */}
</ColorArea.Root><ColorArea.Root
bind:value={() => colorState.color, colorState.setColor}
colorSpace="hsl"
xChannel="h"
yChannel="l"
xInverted
yInverted
>
<!-- ... -->
</ColorArea.Root><div
urcColorAreaRoot
[(value)]="color"
colorSpace="hsl"
xChannel="h"
yChannel="l"
xInverted
yInverted
>
<!-- ... -->
</div>Listening to changes
Vue emits @change on every change, including mid-drag, and @change-end on release. React calls onValueChange while dragging and onValueCommit on release. Svelte does the same as React; Angular emits (valueChange) while dragging and (valueCommit) on release. 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("dragging", color.toString());
};
const onColorChangeEnd = (color: Color) => {
console.log("committed", color.toString());
};
</script>
<template>
<ColorAreaRoot
v-model="color"
color-space="hsl"
x-channel="h"
y-channel="s"
@change="onColorChange"
@change-end="onColorChangeEnd"
>
<!-- ... -->
</ColorAreaRoot>
</template>import { Color } from "@urcolor/core";
const onColorChange = (color: Color) => {
console.log("dragging", color.toString());
};
const onColorCommit = (color: Color) => {
console.log("committed", color.toString());
};
<ColorArea.Root
value={color}
onValueChange={onColorChange}
onValueCommit={onColorCommit}
colorSpace="hsl"
xChannel="h"
yChannel="s"
>
{/* ... */}
</ColorArea.Root><script lang="ts">
import type { Color } from "@urcolor/core";
// ...
const onColorChange = (color: Color) => {
console.log("dragging", color.toString());
};
const onColorCommit = (color: Color) => {
console.log("committed", color.toString());
};
</script>
<ColorArea.Root
bind:value={() => colorState.color, colorState.setColor}
colorSpace="hsl"
xChannel="h"
yChannel="s"
onValueChange={onColorChange}
onValueCommit={onColorCommit}
>
<!-- ... -->
</ColorArea.Root>import { Component, signal } from "@angular/core";
import { Color } from "@urcolor/core";
import { COLOR_AREA_DIRECTIVES } from "@urcolor/angular";
@Component({
selector: "my-area",
imports: [...COLOR_AREA_DIRECTIVES],
template: `
<div
urcColorAreaRoot
[value]="color()"
(valueChange)="onColorChange($event)"
(valueCommit)="onColorCommit($event)"
colorSpace="hsl"
xChannel="h"
yChannel="s"
>
<!-- ... -->
</div>
`,
})
export class MyArea {
protected readonly color = signal<Color>(Color.parse("hsl(210, 80%, 50%)")!);
protected onColorChange(color: Color): void {
this.color.set(color);
console.log("dragging", color.toString());
}
protected onColorCommit(color: Color): void {
console.log("committed", color.toString());
}
}In Vue, @update:model-value and @update:color fire alongside @change; use whichever suits your binding style.