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
<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>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>
);
}<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>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.
<script setup lang="ts">
import { useColor } from "@urcolor/vue";
const { color } = useColor("hsl(210, 80%, 50%)");
</script>import { useColor } from "@urcolor/react";
function MyWheel() {
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-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.
<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>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>
);
}<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>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.
<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>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>
);
}<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>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.
<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>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>
);
}<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>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:
<template>
<ColorWheelRoot
v-model="color"
color-space="hsl"
:start-angle="90"
angle-channel="h"
radius-channel="s"
>
<!-- ... -->
</ColorWheelRoot>
</template><ColorWheel.Root
value={color}
onValueChange={setColor}
colorSpace="hsl"
startAngle={90}
channelAngle="h"
channelRadius="s"
>
{/* ... */}
</ColorWheel.Root><ColorWheel.Root
bind:value={() => colorState.color, colorState.setColor}
colorSpace="hsl"
startAngle={90}
angleChannel="h"
radiusChannel="s"
>
<!-- ... -->
</ColorWheel.Root><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:
<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>const { color, setColor } = useColor("oklch(0.6 0.15 210)");
<ColorWheel.Root
value={color}
onValueChange={setColor}
colorSpace="oklch"
channelAngle="h"
channelRadius="c"
>
{/* ... */}
</ColorWheel.Root><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>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.
<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>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><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>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());
}
}