Skip to content

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
vue
<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>
tsx
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>
  );
}
svelte
<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>
ts
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.

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 MyArea() {
  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-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.

vue
<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>
tsx
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>
  );
}
svelte
<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>
ts
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 axis
  • y-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.

vue
<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>
tsx
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>
  );
}
svelte
<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>
ts
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.

vue
<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>
tsx
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>
  );
}
svelte
<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>
ts
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.

vue
<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>
tsx
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>
  );
}
svelte
<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>
ts
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:

vue
<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>
tsx
const { color, setColor } = useColor("oklch(0.6 0.15 210)");

<ColorArea.Root
  value={color}
  onValueChange={setColor}
  colorSpace="oklch"
  xChannel="c"
  yChannel="l"
>
  {/* ... */}
</ColorArea.Root>
svelte
<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>
ts
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:

vue
<template>
  <ColorAreaRoot
    v-model="color"
    color-space="hsl"
    x-channel="s"
    y-channel="l"
  >
    <!-- ... -->
  </ColorAreaRoot>
</template>
tsx
<ColorArea.Root
  value={color}
  onValueChange={setColor}
  colorSpace="hsl"
  xChannel="s"
  yChannel="l"
>
  {/* ... */}
</ColorArea.Root>
svelte
<ColorArea.Root
  bind:value={() => colorState.color, colorState.setColor}
  colorSpace="hsl"
  xChannel="s"
  yChannel="l"
>
  <!-- ... -->
</ColorArea.Root>
html
<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.

vue
<template>
  <ColorAreaRoot
    v-model="color"
    color-space="hsl"
    x-channel="h"
    y-channel="l"
    :x-inverted="true"
    :y-inverted="true"
  >
    <!-- ... -->
  </ColorAreaRoot>
</template>
tsx
<ColorArea.Root
  value={color}
  onValueChange={setColor}
  colorSpace="hsl"
  xChannel="h"
  yChannel="l"
  xInverted
  yInverted
>
  {/* ... */}
</ColorArea.Root>
svelte
<ColorArea.Root
  bind:value={() => colorState.color, colorState.setColor}
  colorSpace="hsl"
  xChannel="h"
  yChannel="l"
  xInverted
  yInverted
>
  <!-- ... -->
</ColorArea.Root>
html
<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.

vue
<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>
tsx
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>
svelte
<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>
ts
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.