Skip to content

How to Build a Color Wheel

A color wheel maps one channel onto the angle and another onto the radius.

Here's what we'll end up with:

Click to view the full code
vue
<script setup lang="ts">
import {
  useColor,
  ColorWheelRoot,
  ColorWheelGradient,
  ColorWheelThumb,
} from "@urcolor/vue";

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

<template>
  <ColorWheelRoot
    v-model="color"
    color-space="hsl"
    angle-channel="h"
    radius-channel="s"
    as="div"
    class="relative block size-64 overflow-hidden rounded-full"
    style="container-type: inline-size"
  >
    <ColorWheelGradient
      as="div"
      class="absolute inset-0 block"
    />
    <ColorWheelThumb
      class="
        size-4 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)]
      "
      aria-label="Color"
    />
  </ColorWheelRoot>
</template>
tsx
import { ColorWheel, useColor } from "@urcolor/react";

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

  return (
    <ColorWheel.Root
      value={color}
      onValueChange={setColor}
      colorSpace="hsl"
      channelAngle="h"
      channelRadius="s"
      className="relative block size-64 overflow-hidden rounded-full"
      style={{ containerType: "inline-size" }}
    >
      <ColorWheel.Gradient className="absolute inset-0 block" />
      <ColorWheel.Thumb
        className="
          size-4 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)]
        "
        aria-label="Color"
      />
    </ColorWheel.Root>
  );
}
svelte
<script lang="ts">
  import { ColorWheel, useColor } from "@urcolor/svelte";

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

<ColorWheel.Root
  bind:value={() => colorState.color, colorState.setColor}
  colorSpace="hsl"
  angleChannel="h"
  radiusChannel="s"
  class="relative block size-64 overflow-hidden rounded-full"
  style="container-type: inline-size"
>
  <ColorWheel.Gradient class="absolute inset-0 block" />
  <ColorWheel.Thumb
    class="
      size-4 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)]
    "
    aria-label="Color"
  />
</ColorWheel.Root>
ts
import { Component, signal } from "@angular/core";
import { Color } from "@urcolor/core";
import { COLOR_WHEEL_DIRECTIVES } from "@urcolor/angular";

@Component({
  selector: "color-wheel-guide",
  imports: [...COLOR_WHEEL_DIRECTIVES],
  template: `
    <div
      urcColorWheelRoot
      [(value)]="color"
      colorSpace="hsl"
      angleChannel="h"
      radiusChannel="s"
      class="relative block size-64 overflow-hidden rounded-full"
      style="container-type: inline-size"
    >
      <canvas urcColorWheelGradient class="absolute inset-0 block"></canvas>
      <div
        urcColorWheelThumb
        class="
          size-4 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)]
        "
        aria-label="Color"
      ></div>
    </div>
  `,
})
export class ColorWheelGuide {
  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 MyWheel() {
  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-wheel",
  template: ``,
})
export class MyWheel {
  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 channels map to the angle and the radius.

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

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

<template>
  <ColorWheelRoot
    v-model="color"
    color-space="hsl"
    angle-channel="h"
    radius-channel="s"
  >
    <!-- children go here -->
  </ColorWheelRoot>
</template>
tsx
import { useColor, ColorWheel } from "@urcolor/react"; 

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

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

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

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

@Component({
  selector: "my-wheel",
  imports: [...COLOR_WHEEL_DIRECTIVES], 
  template: `
    <div
      urcColorWheelRoot
      [(value)]="color"
      colorSpace="hsl"
      angleChannel="h"
      radiusChannel="s"
    >
      <!-- children go here -->
    </div>
  `,
})
export class MyWheel {
  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, etc.)
  • angle-channel / channelAngle / angleChannel: the channel mapped to the angular axis (rotation)
  • radius-channel / channelRadius / radiusChannel: the channel mapped to the radial axis (distance from center)

React names these two props channelAngle / channelRadius; Vue, Svelte, and Angular all name them angleChannel / radiusChannel (kebab-cased in Vue templates).

Step 3: Add the gradient

The gradient paints the disc on a canvas. In Angular the gradient's selector is canvas[urcColorWheelGradient], so it goes on a <canvas> element you own; the other three render their own canvas for you.

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

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

<template>
  <ColorWheelRoot
    v-model="color"
    color-space="hsl"
    angle-channel="h"
    radius-channel="s"
    class="relative block size-64 overflow-hidden rounded-full"
    style="container-type: inline-size"
  >
    <ColorWheelGradient class="absolute inset-0 block" /> 
  </ColorWheelRoot>
</template>
tsx
import { useColor, ColorWheel } from "@urcolor/react";

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

  return (
    <ColorWheel.Root
      value={color}
      onValueChange={setColor}
      colorSpace="hsl"
      channelAngle="h"
      channelRadius="s"
      className="relative block size-64 overflow-hidden rounded-full"
      style={{ containerType: "inline-size" }}
    >
      <ColorWheel.Gradient className="absolute inset-0 block" /> {}
    </ColorWheel.Root>
  );
}
svelte
<script lang="ts">
  import { ColorWheel, useColor } from "@urcolor/svelte";

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

<ColorWheel.Root
  bind:value={() => colorState.color, colorState.setColor}
  colorSpace="hsl"
  angleChannel="h"
  radiusChannel="s"
  class="relative block size-64 overflow-hidden rounded-full"
  style="container-type: inline-size"
>
  <ColorWheel.Gradient class="absolute inset-0 block" /> 
</ColorWheel.Root>
ts
import { Component, signal } from "@angular/core";
import { Color } from "@urcolor/core";
import { COLOR_WHEEL_DIRECTIVES } from "@urcolor/angular";

@Component({
  selector: "my-wheel",
  imports: [...COLOR_WHEEL_DIRECTIVES],
  template: `
    <div
      urcColorWheelRoot
      [(value)]="color"
      colorSpace="hsl"
      angleChannel="h"
      radiusChannel="s"
      class="relative block size-64 overflow-hidden rounded-full"
      style="container-type: inline-size"
    >
      <canvas urcColorWheelGradient class="absolute inset-0 block"></canvas>
    </div>
  `,
})
export class MyWheel {
  protected readonly color = signal<Color>(Color.parse("hsl(210, 80%, 50%)")!);
}

The root needs overflow-hidden rounded-full to clip the gradient to a circle, and container-type: inline-size for the component to measure itself.

Step 4: Add the thumb

The thumb is the draggable handle. The component positions it inside the wheel.

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

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

<template>
  <ColorWheelRoot
    v-model="color"
    color-space="hsl"
    angle-channel="h"
    radius-channel="s"
    class="relative block size-64 overflow-hidden rounded-full"
    style="container-type: inline-size"
  >
    <ColorWheelGradient class="absolute inset-0 block" />
    <ColorWheelThumb
      class="
        size-4 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)]
      "
      aria-label="Color"
    />
  </ColorWheelRoot>
</template>
tsx
import { useColor, ColorWheel } from "@urcolor/react";

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

  return (
    <ColorWheel.Root
      value={color}
      onValueChange={setColor}
      colorSpace="hsl"
      channelAngle="h"
      channelRadius="s"
      className="relative block size-64 overflow-hidden rounded-full"
      style={{ containerType: "inline-size" }}
    >
      <ColorWheel.Gradient className="absolute inset-0 block" />
      <ColorWheel.Thumb
        className="
          size-4 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)]
        "
        aria-label="Color"
      />
    </ColorWheel.Root>
  );
}
svelte
<script lang="ts">
  import { ColorWheel, useColor } from "@urcolor/svelte";

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

<ColorWheel.Root
  bind:value={() => colorState.color, colorState.setColor}
  colorSpace="hsl"
  angleChannel="h"
  radiusChannel="s"
  class="relative block size-64 overflow-hidden rounded-full"
  style="container-type: inline-size"
>
  <ColorWheel.Gradient class="absolute inset-0 block" />
  <ColorWheel.Thumb
    class="
      size-4 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)]
    "
    aria-label="Color"
  />
</ColorWheel.Root>
ts
import { Component, signal } from "@angular/core";
import { Color } from "@urcolor/core";
import { COLOR_WHEEL_DIRECTIVES } from "@urcolor/angular";

@Component({
  selector: "my-wheel",
  imports: [...COLOR_WHEEL_DIRECTIVES],
  template: `
    <div
      urcColorWheelRoot
      [(value)]="color"
      colorSpace="hsl"
      angleChannel="h"
      radiusChannel="s"
      class="relative block size-64 overflow-hidden rounded-full"
      style="container-type: inline-size"
    >
      <canvas urcColorWheelGradient class="absolute inset-0 block"></canvas>
      <div
        urcColorWheelThumb
        class="
          size-4 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)]
        "
        aria-label="Color"
      ></div>
    </div>
  `,
})
export class MyWheel {
  protected readonly color = signal<Color>(Color.parse("hsl(210, 80%, 50%)")!);
}

All four bindings ship one combined Thumb for the wheel: a single handle drives both axes, with no thumb per channel.

TIP

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

Start angle offset

Rotating where the wheel gradient begins, in degrees:

vue
<template>
  <ColorWheelRoot
    v-model="color"
    color-space="hsl"
    :start-angle="90"
    angle-channel="h"
    radius-channel="s"
  >
    <!-- ... -->
  </ColorWheelRoot>
</template>
tsx
<ColorWheel.Root
  value={color}
  onValueChange={setColor}
  colorSpace="hsl"
  startAngle={90}
  channelAngle="h"
  channelRadius="s"
>
  {/* ... */}
</ColorWheel.Root>
svelte
<ColorWheel.Root
  bind:value={() => colorState.color, colorState.setColor}
  colorSpace="hsl"
  startAngle={90}
  angleChannel="h"
  radiusChannel="s"
>
  <!-- ... -->
</ColorWheel.Root>
html
<div
  urcColorWheelRoot
  [(value)]="color"
  colorSpace="hsl"
  startAngle="90"
  angleChannel="h"
  radiusChannel="s"
>
  <!-- ... -->
</div>

Different color spaces

Switching the color space and the channel mapping changes what the wheel does. In OKLCh:

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

const { color } = useColor("oklch(0.6 0.15 210)");
</script>

<template>
  <ColorWheelRoot
    v-model="color"
    color-space="oklch"
    angle-channel="h"
    radius-channel="c"
  >
    <!-- ... -->
  </ColorWheelRoot>
</template>
tsx
const { color, setColor } = useColor("oklch(0.6 0.15 210)");

<ColorWheel.Root
  value={color}
  onValueChange={setColor}
  colorSpace="oklch"
  channelAngle="h"
  channelRadius="c"
>
  {/* ... */}
</ColorWheel.Root>
svelte
<script lang="ts">
  import { useColor } from "@urcolor/svelte";

  const colorState = useColor("oklch(0.6 0.15 210)");
</script>

<ColorWheel.Root
  bind:value={() => colorState.color, colorState.setColor}
  colorSpace="oklch"
  angleChannel="h"
  radiusChannel="c"
>
  <!-- ... -->
</ColorWheel.Root>
ts
import { Component, signal } from "@angular/core";
import { Color } from "@urcolor/core";
import { COLOR_WHEEL_DIRECTIVES } from "@urcolor/angular";

@Component({
  selector: "my-wheel",
  imports: [...COLOR_WHEEL_DIRECTIVES],
  template: `
    <div
      urcColorWheelRoot
      [(value)]="color"
      colorSpace="oklch"
      angleChannel="h"
      radiusChannel="c"
    >
      <!-- ... -->
    </div>
  `,
})
export class MyWheel {
  protected readonly color = signal<Color>(Color.parse("oklch(0.6 0.15 210)")!);
}

Listening to changes

Vue emits @update:model-value while dragging and @change-end on release; React and Svelte both call onValueChange while dragging and onValueCommit on release; 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>
  <ColorWheelRoot
    v-model="color"
    color-space="hsl"
    @update:model-value="onColorChange"
    @change-end="onColorChangeEnd"
    angle-channel="h"
    radius-channel="s"
  >
    <!-- ... -->
  </ColorWheelRoot>
</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());
};

<ColorWheel.Root
  value={color}
  onValueChange={onColorChange}
  onValueCommit={onColorCommit}
  colorSpace="hsl"
  channelAngle="h"
  channelRadius="s"
>
  {/* ... */}
</ColorWheel.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>

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

@Component({
  selector: "my-wheel",
  imports: [...COLOR_WHEEL_DIRECTIVES],
  template: `
    <div
      urcColorWheelRoot
      [value]="color()"
      (valueChange)="onColorChange($event)"
      (valueCommit)="onColorCommit($event)"
      colorSpace="hsl"
      angleChannel="h"
      radiusChannel="s"
    >
      <!-- ... -->
    </div>
  `,
})
export class MyWheel {
  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());
  }
}