Skip to content

ColorSlider

A 1D slider component for adjusting a single color channel, with a gradient track that reflects the current color.

Preview

Source code
vue
<script setup lang="ts">
import {
  ColorSliderRoot,
  ColorSliderTrack,
  ColorSliderGradient,
  ColorSliderThumb,
  useColor,
} 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>

Anatomy

vue
<template>
  <ColorSliderRoot>
    <ColorSliderTrack>
      <ColorSliderGradient />
      <ColorSliderRange />
      <ColorSliderThumb />
    </ColorSliderTrack>
  </ColorSliderRoot>
</template>

ColorSliderRange is optional. Add it only when you want a filled portion of the track.

TIP

The Vue package has no Control part. React needs one because Base UI handles pointer interaction on Slider.Control; Reka UI's slider does not, and Svelte and Angular ship an optional Control that is a pure styling hook.

Examples

Hue

Source code
vue
<script setup lang="ts">
import {
  ColorSliderRoot,
  ColorSliderTrack,
  ColorSliderGradient,
  ColorSliderThumb,
  useColor,
} 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>

Saturation

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

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

const gradientColors = computed(() => {
  if (!color.value) return ["gray", "blue"];
  const cfg = getChannelConfig("hsl", "s");
  if (!cfg) return ["gray", "blue"];
  const steps = 7;
  const colors: string[] = [];
  const cMin = cfg.nativeMin ?? cfg.min;
  const cMax = cfg.nativeMax ?? cfg.max;
  for (let i = 0; i < steps; i++) {
    const t = i / (steps - 1);
    const val = cMin + t * (cMax - cMin);
    colors.push(color.value.with({ space: "hsl", s: val }).toString());
  }
  return colors;
});
</script>

<template>
  <ColorSliderRoot
    v-model="color"
    color-space="hsl"
    channel="s"
    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="gradientColors"
      />
      <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="Saturation"
      />
    </ColorSliderTrack>
  </ColorSliderRoot>
</template>

Lightness

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

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

const gradientColors = computed(() => {
  if (!color.value) return ["black", "white"];
  const cfg = getChannelConfig("hsl", "l");
  if (!cfg) return ["black", "white"];
  const steps = 7;
  const colors: string[] = [];
  const cMin = cfg.nativeMin ?? cfg.min;
  const cMax = cfg.nativeMax ?? cfg.max;
  for (let i = 0; i < steps; i++) {
    const t = i / (steps - 1);
    const val = cMin + t * (cMax - cMin);
    colors.push(color.value.with({ space: "hsl", l: val }).toString());
  }
  return colors;
});
</script>

<template>
  <ColorSliderRoot
    v-model="color"
    color-space="hsl"
    channel="l"
    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="gradientColors"
      />
      <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="Lightness"
      />
    </ColorSliderTrack>
  </ColorSliderRoot>
</template>

Vertical

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

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

<template>
  <ColorSliderRoot
    v-model="color"
    color-space="hsl"
    channel="h"
    orientation="vertical"
    as="div"
    class="h-[150px] w-auto"
  >
    <ColorSliderTrack
      as="div"
      class="relative h-full w-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 (vertical)"
      />
    </ColorSliderTrack>
  </ColorSliderRoot>
</template>

With Alpha

Pass :channel-overrides="false" on ColorSliderGradient to reflect the color's alpha as opacity on the gradient. ColorSliderGradient paints the checkerboard behind the canvas automatically, so transparency is visible with no extra element.

vue
<template>
  <ColorSliderRoot
    :model-value="color"
    color-space="hsl"
    channel="h"
    @update:model-value="onColorUpdate"
  >
    <ColorSliderTrack>
      <ColorSliderGradient
        :colors="['red', 'yellow', 'lime', 'cyan', 'blue', 'magenta', 'red']"
        :channel-overrides="false"
      />
      <ColorSliderThumb />
    </ColorSliderTrack>
  </ColorSliderRoot>
</template>

Alpha Channel

Set channel="alpha" to create an opacity slider. The gradient automatically renders with transparency.

vue
<template>
  <ColorSliderRoot
    :model-value="color"
    color-space="hsl"
    channel="alpha"
    @update:model-value="onColorUpdate"
  >
    <ColorSliderTrack>
      <ColorSliderGradient :colors="['hsla(210, 80%, 50%, 0)', 'hsl(210, 80%, 50%)']" />
      <ColorSliderThumb />
    </ColorSliderTrack>
  </ColorSliderRoot>
</template>

API Reference

The root's context is readable with injectColorSliderRootContext().

ColorSliderRoot

The root container that manages slider state and color channel binding. Renders Reka UI's SliderRoot.

PropTypeDefaultDescription
modelValueColor | string | nullControlled color value (v-model).
defaultValueColor | string'hsl(0, 100%, 50%)'Initial color when uncontrolled.
colorSpaceSpaceId'hsl'Color space (e.g. 'hsl', 'oklch').
channelstring'h'Channel to control (e.g. 'h', 's', 'l', 'alpha').
stepnumberAutoStepping interval. Derived from the channel config when omitted.
disabledbooleanfalseDisables interaction.
dir'ltr' | 'rtl'Reading direction.
invertedbooleanfalseVisually invert the slider.
orientation'horizontal' | 'vertical''horizontal'Slider orientation.
namestringHidden input name for form submission.
requiredbooleanfalseMarks as required for form submission.
asstring'span'The element or component to render as.
asChildbooleanfalseMerge props onto the single child instead of rendering an element.
EventPayloadDescription
update:modelValueColor | undefinedEmitted whenever the color changes.
update:colorColorMirrors update:modelValue; present for API parity.
changeColorEmitted on every value change, including mid-drag.
changeEndColorEmitted when a change-producing interaction ends.

The default slot receives { modelValue }, the current Color.

ColorSliderTrack

The track area that contains the gradient and thumb. Renders Reka UI's SliderTrack, a <span>, and extends its SliderTrackProps.

ColorSliderGradient

Renders the slider's color ramp inside a wrapper element. A one-dimensional ramp has an exact CSS equivalent in every color space, so by default this paints a linear-gradient and renders no <canvas> at all, it appears in server-rendered HTML and costs no WebGL context. The transparency checkerboard is the wrapper's own CSS background, which the gradient composites over, so no separate part is needed for it.

Setting interpolationSpace does not change that: the stops are computed in the requested space and emitted densely, so the CSS path stays exact.

PropTypeDefaultDescription
renderer'auto' | 'css' | 'canvas''auto'Which painter to use. 'auto' paints with stacked CSS gradients when an exact recipe exists for the color space and channels, and falls back to the canvas otherwise. 'css' forces the CSS path and warns in development if no recipe exists. 'canvas' always paints into a <canvas>.
colorsstring[]AutoArray of color stops. Computed from the slider's channel and current color when omitted: 36 stops across a cyclic channel such as hue, 16 otherwise, or 12 on the canvas path, which the shader's uniform slots cap. At least two valid stops are required, or nothing is painted.
anglenumberAutoGradient rotation in degrees (0 = left-to-right, 90 = top-to-bottom). Normalized to 0–360; defaults to 90 when the slider is vertical, 0 otherwise.
interpolationSpaceSpaceIdColor space for perceptual interpolation (e.g. 'oklch').
channelOverridesRecord<string, number> | false{ alpha: 1 }Lock specific channels to fixed values in the gradient. Set to false to reflect all channels from current color including alpha. E.g. { s: 1, v: 1, alpha: 1 } for an immutable hue gradient in HSV.
asstring'span'The element or component to render as.
asChildbooleanfalseMerge props onto the single child instead of rendering an element.

ColorSliderCheckerboard deprecated

Deprecated

ColorSliderGradient now paints the checkerboard itself, so this component is no longer needed and is kept only for backwards compatibility. It emits a one-time console warning in development. To render a checkerboard elsewhere, apply a CSS repeating-conic-gradient background to your own element.

Renders a checkerboard pattern behind the gradient to visualize alpha transparency. Place it inside ColorSliderTrack before ColorSliderGradient.

ColorSliderThumb

The draggable handle, and the slider's only focusable element. Renders Reka UI's SliderThumb, which supplies role="slider", the value ARIA and tabindex. On top of that this part sets aria-label from the channel's label and aria-valuetext from the formatted channel value.

Extends Reka UI's SliderThumbProps.

PropTypeDefaultDescription
aria-labelstringChannel labelOverrides the generated label, e.g. "Hue". Passed as a fallthrough attribute.
asstring'span'The element or component to render as.
asChildbooleanfalseMerge props onto the single child instead of rendering an element.

The default slot receives { channelName, channelValue }.

ColorSliderRange

The filled range portion of the track. Renders Reka UI's SliderRange and extends its SliderRangeProps.

Data Attributes

Reka UI applies these to the parts it renders.

AttributePartPresent when
data-orientationRoot, Track, Range, ThumbAlways; the value is horizontal or vertical.
data-disabledRoot, Track, Range, ThumbThe root is disabled.

ColorSliderGradient and ColorSliderCheckerboard are plain elements and carry no state attributes; style them from an ancestor's.

CSS Variables

The transparency grid reads three custom properties and no component writes them, so a rule anywhere above the element wins:

VariableDefaultDescription
--urcolor-checkerboard-darkrgb(230, 230, 230)The darker of the two checks.
--urcolor-checkerboard-lightwhiteThe lighter of the two checks.
--urcolor-checkerboard-size16pxThe tile size, applied to both axes.

The grid is a single background shorthand, so an invalid value invalidates the whole declaration rather than its own layer. Keep overrides to a <color> and a <length>.

Accessibility

ColorSlider provides a standard slider interface built on top of Reka UI's slider primitives, ensuring robust screen reader support.

ARIA Labels

AttributeDescription
role="slider"Applied to ColorSliderThumb for screen reader recognition.
aria-labelDefaults to the channel's label, e.g. "Hue" or "Alpha". Pass your own aria-label on the thumb to override.
aria-valuemin / aria-valuemaxThe channel's range in display units.
aria-valuenowThe current channel value in display units.
aria-valuetextThe value formatted with its unit, e.g. "210°", "80%".
aria-orientationReflects the root's orientation.

Keyboard Navigation

KeyAction
Arrow Right / Arrow UpIncrease by one step
Arrow Left / Arrow DownDecrease by one step
Shift + ArrowMove by 10 steps
Page Up / Page DownIncrease / decrease by 10 steps
HomeMove to the channel minimum
EndMove to the channel maximum

changeEnd fires once when a change-producing interaction ends, not on every key repeat.