Skip to content

How to Build a Color Channel Slider

A color slider maps one channel onto a track.

Here's what we'll end up with:

Click to view the full code
vue
<script setup lang="ts">
import {
  useColor,
  ColorSliderRoot,
  ColorSliderTrack,
  ColorSliderGradient,
  ColorSliderThumb,
} from "@urcolor/vue";

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

<template>
  <ColorSliderRoot
    v-model="color"
    color-space="hsl"
    channel="h"
    as="div"
    class="w-full"
  >
    <ColorSliderTrack
      as="div"
      class="relative h-5 overflow-hidden rounded-xl"
    >
      <ColorSliderGradient
        as="div"
        class="absolute inset-0 rounded-xl"
        :colors="['red', 'yellow', 'lime', 'cyan', 'blue', 'magenta', 'red']"
      />
      <ColorSliderThumb
        class="
          block size-5 rounded-full border-[2.5px] border-white bg-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="Hue"
      />
    </ColorSliderTrack>
  </ColorSliderRoot>
</template>
tsx
import { ColorSlider, useColor } from "@urcolor/react";

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

  return (
    <ColorSlider.Root
      value={color}
      onValueChange={setColor}
      colorSpace="hsl"
      channel="h"
      className="w-full"
    >
      <ColorSlider.Control>
        <ColorSlider.Track className="relative h-5 overflow-hidden rounded-xl">
          <ColorSlider.Gradient
            className="absolute inset-0 rounded-xl"
            colors={["red", "yellow", "lime", "cyan", "blue", "magenta", "red"]}
          />
          <ColorSlider.Thumb
            className="
              block size-5 rounded-full border-[2.5px] border-white bg-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="Hue"
          />
        </ColorSlider.Track>
      </ColorSlider.Control>
    </ColorSlider.Root>
  );
}
svelte
<script lang="ts">
  import { ColorSlider, useColor } from "@urcolor/svelte";

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

<ColorSlider.Root
  bind:value={() => colorState.color, colorState.setColor}
  colorSpace="hsl"
  channel="h"
  class="w-full"
>
  <ColorSlider.Track class="relative h-5 overflow-hidden rounded-xl">
    <ColorSlider.Gradient
      class="absolute inset-0 rounded-xl"
      colors={["red", "yellow", "lime", "cyan", "blue", "magenta", "red"]}
    />
    <ColorSlider.Thumb
      class="
        block size-5 rounded-full border-[2.5px] border-white bg-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="Hue"
    />
  </ColorSlider.Track>
</ColorSlider.Root>
ts
import { Component, signal } from "@angular/core";
import { Color } from "@urcolor/core";
import { COLOR_SLIDER_DIRECTIVES } from "@urcolor/angular";

@Component({
  selector: "color-slider-guide",
  imports: [...COLOR_SLIDER_DIRECTIVES],
  template: `
    <div urcColorSliderRoot [(value)]="color" colorSpace="hsl" channel="h" class="w-full">
      <div urcColorSliderTrack class="relative h-5 overflow-hidden rounded-xl">
        <canvas
          urcColorSliderGradient
          class="absolute inset-0 rounded-xl"
          [colors]="['red', 'yellow', 'lime', 'cyan', 'blue', 'magenta', 'red']"
        ></canvas>
        <div
          urcColorSliderThumb
          class="
            block size-5 rounded-full border-[2.5px] border-white bg-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="Hue"
        ></div>
      </div>
    </div>
  `,
})
export class ColorSliderGuide {
  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 MySlider() {
  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-slider",
  template: ``,
})
export class MySlider {
  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 channel to control.

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

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

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

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

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

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

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

@Component({
  selector: "my-slider",
  imports: [...COLOR_SLIDER_DIRECTIVES], 
  template: `
    <div
      urcColorSliderRoot
      [(value)]="color"
      colorSpace="hsl"
      channel="h"
    >
      <!-- children go here -->
    </div>
  `,
})
export class MySlider {
  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.)
  • channel: the channel this slider controls (h, s, l, c, alpha, etc.)

Step 3: Add the track and gradient

The track is the interactive area that handles pointer events, and the gradient renders the 1D gradient on a canvas. React additionally wraps the track in a ColorSlider.Control element, because Base UI's slider requires it. Vue, Svelte and Angular go straight from the root to the track. They ship a Control part too, but only as an optional styling hook. In Angular the gradient's selector is canvas[urcColorSliderGradient], 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,
  ColorSliderRoot,
  ColorSliderTrack, 
  ColorSliderGradient, 
} from "@urcolor/vue";

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

<template>
  <ColorSliderRoot
    v-model="color"
    color-space="hsl"
    channel="h"
  >
    <ColorSliderTrack
      class="relative h-5 overflow-hidden rounded-xl"
    >
      <ColorSliderGradient
        class="absolute inset-0 rounded-xl"
        :colors="['red', 'yellow', 'lime', 'cyan', 'blue', 'magenta', 'red']"
      />
    </ColorSliderTrack>
  </ColorSliderRoot>
</template>
tsx
import { useColor, ColorSlider } from "@urcolor/react";

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

  return (
    <ColorSlider.Root
      value={color}
      onValueChange={setColor}
      colorSpace="hsl"
      channel="h"
    >
      <ColorSlider.Control>
        <ColorSlider.Track
          className="relative h-5 overflow-hidden rounded-xl"
        >
          <ColorSlider.Gradient
            className="absolute inset-0 rounded-xl"
            colors={["red", "yellow", "lime", "cyan", "blue", "magenta", "red"]}
          />
        </ColorSlider.Track>
      </ColorSlider.Control>
    </ColorSlider.Root>
  );
}
svelte
<script lang="ts">
  import { ColorSlider, useColor } from "@urcolor/svelte";

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

<ColorSlider.Root
  bind:value={() => colorState.color, colorState.setColor}
  colorSpace="hsl"
  channel="h"
>
  <ColorSlider.Track class="relative h-5 overflow-hidden rounded-xl">
    <ColorSlider.Gradient
      class="absolute inset-0 rounded-xl"
      colors={["red", "yellow", "lime", "cyan", "blue", "magenta", "red"]}
    />
  </ColorSlider.Track>
</ColorSlider.Root>
ts
import { Component, signal } from "@angular/core";
import { Color } from "@urcolor/core";
import { COLOR_SLIDER_DIRECTIVES } from "@urcolor/angular";

@Component({
  selector: "my-slider",
  imports: [...COLOR_SLIDER_DIRECTIVES],
  template: `
    <div
      urcColorSliderRoot
      [(value)]="color"
      colorSpace="hsl"
      channel="h"
    >
      <div urcColorSliderTrack class="relative h-5 overflow-hidden rounded-xl">
        <canvas
          urcColorSliderGradient
          class="absolute inset-0 rounded-xl"
          [colors]="['red', 'yellow', 'lime', 'cyan', 'blue', 'magenta', 'red']"
        ></canvas>
      </div>
    </div>
  `,
})
export class MySlider {
  protected readonly color = signal<Color>(Color.parse("hsl(210, 80%, 50%)")!);
}

The colors prop sets the gradient stops. A hue slider wants the full spectrum; other channels need fewer, because the gradient interpolates between them.

Step 4: Add the thumb

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

vue
<script setup lang="ts">
import {
  useColor,
  ColorSliderRoot,
  ColorSliderTrack,
  ColorSliderGradient,
  ColorSliderThumb, 
} from "@urcolor/vue";

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

<template>
  <ColorSliderRoot
    v-model="color"
    color-space="hsl"
    channel="h"
  >
    <ColorSliderTrack
      class="relative h-5 overflow-hidden rounded-xl"
    >
      <ColorSliderGradient
        class="absolute inset-0 rounded-xl"
        :colors="['red', 'yellow', 'lime', 'cyan', 'blue', 'magenta', 'red']"
      />
      <ColorSliderThumb
        class="
          block size-5 rounded-full border-[2.5px] border-white bg-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="Hue"
      />
    </ColorSliderTrack>
  </ColorSliderRoot>
</template>
tsx
import { useColor, ColorSlider } from "@urcolor/react";

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

  return (
    <ColorSlider.Root
      value={color}
      onValueChange={setColor}
      colorSpace="hsl"
      channel="h"
    >
      <ColorSlider.Control>
        <ColorSlider.Track className="relative h-5 overflow-hidden rounded-xl">
          <ColorSlider.Gradient
            className="absolute inset-0 rounded-xl"
            colors={["red", "yellow", "lime", "cyan", "blue", "magenta", "red"]}
          />
          <ColorSlider.Thumb
            className="
              block size-5 rounded-full border-[2.5px] border-white bg-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="Hue"
          />
        </ColorSlider.Track>
      </ColorSlider.Control>
    </ColorSlider.Root>
  );
}
svelte
<script lang="ts">
  import { ColorSlider, useColor } from "@urcolor/svelte";

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

<ColorSlider.Root
  bind:value={() => colorState.color, colorState.setColor}
  colorSpace="hsl"
  channel="h"
>
  <ColorSlider.Track class="relative h-5 overflow-hidden rounded-xl">
    <ColorSlider.Gradient
      class="absolute inset-0 rounded-xl"
      colors={["red", "yellow", "lime", "cyan", "blue", "magenta", "red"]}
    />
    <ColorSlider.Thumb
      class="
        block size-5 rounded-full border-[2.5px] border-white bg-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="Hue"
    />
  </ColorSlider.Track>
</ColorSlider.Root>
ts
import { Component, signal } from "@angular/core";
import { Color } from "@urcolor/core";
import { COLOR_SLIDER_DIRECTIVES } from "@urcolor/angular";

@Component({
  selector: "my-slider",
  imports: [...COLOR_SLIDER_DIRECTIVES],
  template: `
    <div
      urcColorSliderRoot
      [(value)]="color"
      colorSpace="hsl"
      channel="h"
    >
      <div urcColorSliderTrack class="relative h-5 overflow-hidden rounded-xl">
        <canvas
          urcColorSliderGradient
          class="absolute inset-0 rounded-xl"
          [colors]="['red', 'yellow', 'lime', 'cyan', 'blue', 'magenta', 'red']"
        ></canvas>
        <div
          urcColorSliderThumb
          class="
            block size-5 rounded-full border-[2.5px] border-white bg-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="Hue"
        ></div>
      </div>
    </div>
  `,
})
export class MySlider {
  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.

Vertical orientation

orientation="vertical" renders a vertical slider:

vue
<template>
  <ColorSliderRoot
    v-model="color"
    color-space="hsl"
    orientation="vertical"
    channel="h"
  >
    <!-- ... -->
  </ColorSliderRoot>
</template>
tsx
<ColorSlider.Root
  value={color}
  onValueChange={setColor}
  colorSpace="hsl"
  orientation="vertical"
  channel="h"
>
  {/* ... */}
</ColorSlider.Root>
svelte
<ColorSlider.Root
  bind:value={() => colorState.color, colorState.setColor}
  colorSpace="hsl"
  orientation="vertical"
  channel="h"
>
  <!-- ... -->
</ColorSlider.Root>
html
<div
  urcColorSliderRoot
  [(value)]="color"
  colorSpace="hsl"
  orientation="vertical"
  channel="h"
>
  <!-- ... -->
</div>

Inverting direction

inverted reverses the slider direction:

vue
<template>
  <ColorSliderRoot
    v-model="color"
    color-space="hsl"
    :inverted="true"
    channel="h"
  >
    <!-- ... -->
  </ColorSliderRoot>
</template>
tsx
<ColorSlider.Root
  value={color}
  onValueChange={setColor}
  colorSpace="hsl"
  inverted
  channel="h"
>
  {/* ... */}
</ColorSlider.Root>
svelte
<ColorSlider.Root
  bind:value={() => colorState.color, colorState.setColor}
  colorSpace="hsl"
  inverted
  channel="h"
>
  <!-- ... -->
</ColorSlider.Root>
html
<div
  urcColorSliderRoot
  [(value)]="color"
  colorSpace="hsl"
  inverted
  channel="h"
>
  <!-- ... -->
</div>

Different channels

Switching the channel prop controls a different property. A lightness slider:

vue
<template>
  <ColorSliderRoot
    v-model="color"
    color-space="hsl"
    channel="l"
  >
    <ColorSliderTrack
      class="relative h-5 overflow-hidden rounded-xl"
    >
      <ColorSliderGradient
        class="absolute inset-0 rounded-xl"
        :colors="['black', 'hsl(210, 80%, 50%)', 'white']"
      />
      <ColorSliderThumb
        class="..."
        aria-label="Lightness"
      />
    </ColorSliderTrack>
  </ColorSliderRoot>
</template>
tsx
<ColorSlider.Root
  value={color}
  onValueChange={setColor}
  colorSpace="hsl"
  channel="l"
>
  <ColorSlider.Control>
    <ColorSlider.Track className="relative h-5 overflow-hidden rounded-xl">
      <ColorSlider.Gradient
        className="absolute inset-0 rounded-xl"
        colors={["black", "hsl(210, 80%, 50%)", "white"]}
      />
      <ColorSlider.Thumb
        className="..."
        aria-label="Lightness"
      />
    </ColorSlider.Track>
  </ColorSlider.Control>
</ColorSlider.Root>
svelte
<ColorSlider.Root
  bind:value={() => colorState.color, colorState.setColor}
  colorSpace="hsl"
  channel="l"
>
  <ColorSlider.Track class="relative h-5 overflow-hidden rounded-xl">
    <ColorSlider.Gradient
      class="absolute inset-0 rounded-xl"
      colors={["black", "hsl(210, 80%, 50%)", "white"]}
    />
    <ColorSlider.Thumb
      class="..."
      aria-label="Lightness"
    />
  </ColorSlider.Track>
</ColorSlider.Root>
html
<div
  urcColorSliderRoot
  [(value)]="color"
  colorSpace="hsl"
  channel="l"
>
  <div urcColorSliderTrack class="relative h-5 overflow-hidden rounded-xl">
    <canvas
      urcColorSliderGradient
      class="absolute inset-0 rounded-xl"
      [colors]="['black', 'hsl(210, 80%, 50%)', 'white']"
    ></canvas>
    <div urcColorSliderThumb class="..." aria-label="Lightness"></div>
  </div>
</div>

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>
  <ColorSliderRoot
    v-model="color"
    color-space="hsl"
    @update:model-value="onColorChange"
    @change-end="onColorChangeEnd"
    channel="h"
  >
    <!-- ... -->
  </ColorSliderRoot>
</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());
};

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

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

@Component({
  selector: "my-slider",
  imports: [...COLOR_SLIDER_DIRECTIVES],
  template: `
    <div
      urcColorSliderRoot
      [value]="color()"
      (valueChange)="onColorChange($event)"
      (valueCommit)="onColorCommit($event)"
      colorSpace="hsl"
      channel="h"
    >
      <!-- ... -->
    </div>
  `,
})
export class MySlider {
  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());
  }
}