Skip to content

How to Build a Color Triangle ​

A color triangle maps two channels onto a triangular gradient, or three in barycentric mode.

Here's what we'll end up with:

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

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

<template>
  <ColorTriangleRoot
    v-model="color"
    color-space="hsv"
    x-channel="s"
    y-channel="v"
    as="div"
    class="relative block size-64"
  >
    <ColorTriangleGradient
      as="div"
      class="absolute inset-0 block"
    />
    <ColorTriangleThumb
      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"
    />
  </ColorTriangleRoot>
</template>
tsx
import { ColorTriangle, useColor } from "@urcolor/react";

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

  return (
    <ColorTriangle.Root
      value={color}
      onValueChange={setColor}
      colorSpace="hsv"
      xChannel="s"
      yChannel="v"
      className="relative block size-64"
    >
      <ColorTriangle.Gradient className="absolute inset-0 block" />
      <ColorTriangle.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"
      />
    </ColorTriangle.Root>
  );
}
svelte
<script lang="ts">
  import { ColorTriangle, useColor } from "@urcolor/svelte";

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

<ColorTriangle.Root
  bind:value={() => colorState.color, colorState.setColor}
  colorSpace="hsv"
  xChannel="s"
  yChannel="v"
  class="relative block size-64"
>
  <ColorTriangle.Gradient class="absolute inset-0 block" />
  <ColorTriangle.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"
  />
</ColorTriangle.Root>
ts
import { Component, signal } from "@angular/core";
import { Color } from "@urcolor/core";
import { COLOR_TRIANGLE_DIRECTIVES } from "@urcolor/angular";

@Component({
  selector: "color-triangle-guide",
  imports: [...COLOR_TRIANGLE_DIRECTIVES],
  template: `
    <div
      urcColorTriangleRoot
      [(value)]="color"
      colorSpace="hsv"
      xChannel="s"
      yChannel="v"
      class="relative block size-64"
    >
      <canvas urcColorTriangleGradient class="absolute inset-0 block"></canvas>
      <div
        urcColorTriangleThumb
        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 ColorTriangleGuide {
  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 MyTriangle() {
  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-triangle",
  template: ``,
})
export class MyTriangle {
  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. createColorStore() from @urcolor/angular is there too, for hex and alpha projections.

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 triangle's axes.

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

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

<template>
  <ColorTriangleRoot
    v-model="color"
    color-space="hsv"
    x-channel="s"
    y-channel="v"
  >
    <!-- children go here -->
  </ColorTriangleRoot>
</template>
tsx
import { useColor, ColorTriangle } from "@urcolor/react"; 

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

  return (
    <ColorTriangle.Root
      value={color}
      onValueChange={setColor}
      colorSpace="hsv"
      xChannel="s"
      yChannel="v"
    >
      {/* children go here */}
    </ColorTriangle.Root>
  );
}
svelte
<script lang="ts">
  import { ColorTriangle, useColor } from "@urcolor/svelte"; 

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

<ColorTriangle.Root
  bind:value={() => colorState.color, colorState.setColor}
  colorSpace="hsv"
  xChannel="s"
  yChannel="v"
>
  <!-- children go here -->
</ColorTriangle.Root>
ts
import { Component, signal } from "@angular/core";
import { Color } from "@urcolor/core";
import { COLOR_TRIANGLE_DIRECTIVES } from "@urcolor/angular"; 

@Component({
  selector: "my-triangle",
  imports: [...COLOR_TRIANGLE_DIRECTIVES], 
  template: `
    <div
      urcColorTriangleRoot
      [(value)]="color"
      colorSpace="hsv"
      xChannel="s"
      yChannel="v"
    >
      <!-- children go here -->
    </div>
  `,
})
export class MyTriangle {
  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 (hsv, hsl, srgb, 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 gradient ​

The gradient paints the plane inside the triangle. In Angular the gradient's selector is canvas[urcColorTriangleGradient], 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,
  ColorTriangleRoot,
  ColorTriangleGradient, 
} from "@urcolor/vue";

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

<template>
  <ColorTriangleRoot
    v-model="color"
    color-space="hsv"
    x-channel="s"
    y-channel="v"
    class="relative block size-64"
  >
    <ColorTriangleGradient class="absolute inset-0 block" /> 
  </ColorTriangleRoot>
</template>
tsx
import { useColor, ColorTriangle } from "@urcolor/react";

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

  return (
    <ColorTriangle.Root
      value={color}
      onValueChange={setColor}
      colorSpace="hsv"
      xChannel="s"
      yChannel="v"
      className="relative block size-64"
    >
      <ColorTriangle.Gradient className="absolute inset-0 block" /> {}
    </ColorTriangle.Root>
  );
}
svelte
<script lang="ts">
  import { ColorTriangle, useColor } from "@urcolor/svelte";

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

<ColorTriangle.Root
  bind:value={() => colorState.color, colorState.setColor}
  colorSpace="hsv"
  xChannel="s"
  yChannel="v"
  class="relative block size-64"
>
  <ColorTriangle.Gradient class="absolute inset-0 block" /> 
</ColorTriangle.Root>
ts
import { Component, signal } from "@angular/core";
import { Color } from "@urcolor/core";
import { COLOR_TRIANGLE_DIRECTIVES } from "@urcolor/angular";

@Component({
  selector: "my-triangle",
  imports: [...COLOR_TRIANGLE_DIRECTIVES],
  template: `
    <div
      urcColorTriangleRoot
      [(value)]="color"
      colorSpace="hsv"
      xChannel="s"
      yChannel="v"
      class="relative block size-64"
    >
      <canvas urcColorTriangleGradient class="absolute inset-0 block"></canvas>
    </div>
  `,
})
export class MyTriangle {
  protected readonly color = signal<Color>(Color.parse("hsl(210, 80%, 50%)")!);
}

Step 4: Add the thumb ​

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

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

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

<template>
  <ColorTriangleRoot
    v-model="color"
    color-space="hsv"
    x-channel="s"
    y-channel="v"
    class="relative block size-64"
  >
    <ColorTriangleGradient class="absolute inset-0 block" />
    <ColorTriangleThumb
      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"
    />
  </ColorTriangleRoot>
</template>
tsx
import { useColor, ColorTriangle } from "@urcolor/react";

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

  return (
    <ColorTriangle.Root
      value={color}
      onValueChange={setColor}
      colorSpace="hsv"
      xChannel="s"
      yChannel="v"
      className="relative block size-64"
    >
      <ColorTriangle.Gradient className="absolute inset-0 block" />
      <ColorTriangle.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"
      />
    </ColorTriangle.Root>
  );
}
svelte
<script lang="ts">
  import { ColorTriangle, useColor } from "@urcolor/svelte";

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

<ColorTriangle.Root
  bind:value={() => colorState.color, colorState.setColor}
  colorSpace="hsv"
  xChannel="s"
  yChannel="v"
  class="relative block size-64"
>
  <ColorTriangle.Gradient class="absolute inset-0 block" />
  <ColorTriangle.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"
  />
</ColorTriangle.Root>
ts
import { Component, signal } from "@angular/core";
import { Color } from "@urcolor/core";
import { COLOR_TRIANGLE_DIRECTIVES } from "@urcolor/angular";

@Component({
  selector: "my-triangle",
  imports: [...COLOR_TRIANGLE_DIRECTIVES],
  template: `
    <div
      urcColorTriangleRoot
      [(value)]="color"
      colorSpace="hsv"
      xChannel="s"
      yChannel="v"
      class="relative block size-64"
    >
      <canvas urcColorTriangleGradient class="absolute inset-0 block"></canvas>
      <div
        urcColorTriangleThumb
        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 MyTriangle {
  protected readonly color = signal<Color>(Color.parse("hsl(210, 80%, 50%)")!);
}

TIP

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

Rotation ​

Rotate the root with CSS. The geometry is fixed, and the root maps pointer positions back through whatever transform it carries, so dragging keeps following the corner it points at:

vue
<template>
  <ColorTriangleRoot
    v-model="color"
    color-space="hsv"
    x-channel="s"
    y-channel="v"
    style="transform: rotate(180deg)"
  >
    <!-- ... -->
  </ColorTriangleRoot>
</template>
tsx
<ColorTriangle.Root
  value={color}
  onValueChange={setColor}
  colorSpace="hsv"
  xChannel="s"
  yChannel="v"
  style={{ transform: "rotate(180deg)" }}
>
  {/* ... */}
</ColorTriangle.Root>
svelte
<ColorTriangle.Root
  bind:value={() => colorState.color, colorState.setColor}
  colorSpace="hsv"
  xChannel="s"
  yChannel="v"
  style="transform: rotate(180deg)"
>
  <!-- ... -->
</ColorTriangle.Root>
html
<div
  urcColorTriangleRoot
  [(value)]="color"
  colorSpace="hsv"
  xChannel="s"
  yChannel="v"
  style="transform: rotate(180deg)"
>
  <!-- ... -->
</div>

Any transform works, not only rotation: scale, skew and their combinations are all undone before a pointer position is read. A transform on an ancestor is not, so keep it on the root itself.

Three-channel mode ​

A Z channel turns on barycentric three-channel mode, mapping all three channels to the triangle's vertices. It suits RGB mixing:

vue
<template>
  <ColorTriangleRoot
    v-model="color"
    color-space="srgb"
    x-channel="r"
    y-channel="g"
    z-channel="b"
  >
    <!-- ... -->
  </ColorTriangleRoot>
</template>
tsx
<ColorTriangle.Root
  value={color}
  onValueChange={setColor}
  colorSpace="srgb"
  xChannel="r"
  yChannel="g"
  zChannel="b"
>
  {/* ... */}
</ColorTriangle.Root>
svelte
<ColorTriangle.Root
  bind:value={() => colorState.color, colorState.setColor}
  colorSpace="srgb"
  xChannel="r"
  yChannel="g"
  zChannel="b"
>
  <!-- ... -->
</ColorTriangle.Root>
html
<div
  urcColorTriangleRoot
  [(value)]="color"
  colorSpace="srgb"
  xChannel="r"
  yChannel="g"
  zChannel="b"
>
  <!-- ... -->
</div>

The first keypress "jumps"

In three-channel mode the values are barycentric coordinates, where only the ratio between them means anything, so every write is renormalized back onto the simplex. An srgb color at r/g/b 50 / 50 / 180 becomes 46 / 45 / 163 the first time you press Arrow Right (which steps red by one and, as a side effect of the renormalization, redistributes all three channels onto the simplex). That is inherent to the geometry, not a bug; from then on the values move smoothly.

Keyboard control follows the same axes as the color area: Arrow Left/Right step X, Arrow Up/Down step Y, and in three-channel mode only, Page Up/Page Down step Z. Hold Shift for ten steps at a time.

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>
  <ColorTriangleRoot
    v-model="color"
    color-space="hsv"
    @update:model-value="onColorChange"
    @change-end="onColorChangeEnd"
    x-channel="s"
    y-channel="v"
  >
    <!-- ... -->
  </ColorTriangleRoot>
</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());
};

<ColorTriangle.Root
  value={color}
  onValueChange={onColorChange}
  onValueCommit={onColorCommit}
  colorSpace="hsv"
  xChannel="s"
  yChannel="v"
>
  {/* ... */}
</ColorTriangle.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>

<ColorTriangle.Root
  bind:value={() => colorState.color, colorState.setColor}
  colorSpace="hsv"
  onValueChange={onColorChange}
  onValueCommit={onColorCommit}
  xChannel="s"
  yChannel="v"
>
  <!-- ... -->
</ColorTriangle.Root>
ts
import { Component, signal } from "@angular/core";
import { Color } from "@urcolor/core";
import { COLOR_TRIANGLE_DIRECTIVES } from "@urcolor/angular";

@Component({
  selector: "my-triangle",
  imports: [...COLOR_TRIANGLE_DIRECTIVES],
  template: `
    <div
      urcColorTriangleRoot
      [value]="color()"
      (valueChange)="onColorChange($event)"
      (valueCommit)="onColorCommit($event)"
      colorSpace="hsv"
      xChannel="s"
      yChannel="v"
    >
      <!-- ... -->
    </div>
  `,
})
export class MyTriangle {
  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());
  }
}