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
<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>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>
);
}<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>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.
<script setup lang="ts">
import { useColor } from "@urcolor/vue";
const { color } = useColor("hsl(210, 80%, 50%)");
</script>import { useColor } from "@urcolor/react";
function MySlider() {
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-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.
<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>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>
);
}<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>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.
<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>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>
);
}<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>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.
<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>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>
);
}<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>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:
<template>
<ColorSliderRoot
v-model="color"
color-space="hsl"
orientation="vertical"
channel="h"
>
<!-- ... -->
</ColorSliderRoot>
</template><ColorSlider.Root
value={color}
onValueChange={setColor}
colorSpace="hsl"
orientation="vertical"
channel="h"
>
{/* ... */}
</ColorSlider.Root><ColorSlider.Root
bind:value={() => colorState.color, colorState.setColor}
colorSpace="hsl"
orientation="vertical"
channel="h"
>
<!-- ... -->
</ColorSlider.Root><div
urcColorSliderRoot
[(value)]="color"
colorSpace="hsl"
orientation="vertical"
channel="h"
>
<!-- ... -->
</div>Inverting direction
inverted reverses the slider direction:
<template>
<ColorSliderRoot
v-model="color"
color-space="hsl"
:inverted="true"
channel="h"
>
<!-- ... -->
</ColorSliderRoot>
</template><ColorSlider.Root
value={color}
onValueChange={setColor}
colorSpace="hsl"
inverted
channel="h"
>
{/* ... */}
</ColorSlider.Root><ColorSlider.Root
bind:value={() => colorState.color, colorState.setColor}
colorSpace="hsl"
inverted
channel="h"
>
<!-- ... -->
</ColorSlider.Root><div
urcColorSliderRoot
[(value)]="color"
colorSpace="hsl"
inverted
channel="h"
>
<!-- ... -->
</div>Different channels
Switching the channel prop controls a different property. A lightness slider:
<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><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><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><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.
<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>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><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>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());
}
}