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
tsx
import { ColorSlider, useColor } from "@urcolor/react";

export default function ColorSliderHue() {
  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>
  );
}

Anatomy

tsx
<ColorSlider.Root>
  <ColorSlider.Control>
    <ColorSlider.Track>
      <ColorSlider.Gradient />
      <ColorSlider.Range />
      <ColorSlider.Thumb />
    </ColorSlider.Track>
  </ColorSlider.Control>
</ColorSlider.Root>

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

TIP

ColorSlider.Control is React-only in the sense that Base UI requires it: Slider.Control, which this part renders, is where the pointer interaction lives, so Track has to be nested inside it. Vue has no Control part at all, and the Svelte and Angular packages ship one that is a pure styling hook because their roots own the pointer handling.

Examples

Hue

Source code
tsx
import { ColorSlider, useColor } from "@urcolor/react";

export default function ColorSliderHue() {
  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>
  );
}

Saturation

Source code
tsx
import { useMemo } from "react";
import { getChannelConfig } from "@urcolor/shared";
import { ColorSlider, useColor } from "@urcolor/react";

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

  const gradientColors = useMemo(() => {
    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.with({ space: "hsl", s: val }).toString());
    }
    return colors;
  }, [color]);

  return (
    <ColorSlider.Root
      value={color}
      onValueChange={setColor}
      colorSpace="hsl"
      channel="s"
      className="w-full"
    >
      <ColorSlider.Control>
        <ColorSlider.Track className="relative h-5 overflow-hidden rounded-xl">
          <ColorSlider.Gradient
            className="absolute inset-0 rounded-xl"
            colors={gradientColors}
          />
          <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="Saturation"
          />
        </ColorSlider.Track>
      </ColorSlider.Control>
    </ColorSlider.Root>
  );
}

Lightness

Source code
tsx
import { useMemo } from "react";
import { getChannelConfig } from "@urcolor/shared";
import { ColorSlider, useColor } from "@urcolor/react";

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

  const gradientColors = useMemo(() => {
    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.with({ space: "hsl", l: val }).toString());
    }
    return colors;
  }, [color]);

  return (
    <ColorSlider.Root
      value={color}
      onValueChange={setColor}
      colorSpace="hsl"
      channel="l"
      className="w-full"
    >
      <ColorSlider.Control>
        <ColorSlider.Track className="relative h-5 overflow-hidden rounded-xl">
          <ColorSlider.Gradient
            className="absolute inset-0 rounded-xl"
            colors={gradientColors}
          />
          <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="Lightness"
          />
        </ColorSlider.Track>
      </ColorSlider.Control>
    </ColorSlider.Root>
  );
}

Vertical

Source code
tsx
import { ColorSlider, useColor } from "@urcolor/react";

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

  return (
    <ColorSlider.Root
      value={color}
      onValueChange={setColor}
      colorSpace="hsl"
      channel="h"
      orientation="vertical"
      className="h-[150px] w-auto"
    >
      <ColorSlider.Control>
        <ColorSlider.Track className="relative h-full w-5 overflow-hidden rounded-xl">
          <ColorSlider.Gradient
            className="absolute inset-0 rounded-xl"
            colors={["red", "yellow", "lime", "cyan", "blue", "magenta", "red"]}
            angle={180}
          />
          <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 (vertical)"
          />
        </ColorSlider.Track>
      </ColorSlider.Control>
    </ColorSlider.Root>
  );
}

API Reference

Every part is also exported unnamespaced, ColorSliderRoot, ColorSliderControl, ColorSliderTrack, ColorSliderRange, ColorSliderThumb, ColorSliderGradient, alongside the ColorSlider.* namespace. The root's context is readable with useColorSliderContext().

ColorSlider.Root

The root container that manages slider state and color channel binding. Renders Base UI's Slider.Root, a <div>.

PropTypeDefaultDescription
valueColor | string | nullControlled color value.
defaultValueColor | string | nullInitial color when uncontrolled. Falls back to hsl(210, 80%, 50%).
colorSpaceSpaceId'hsl'Color space (e.g. 'hsl', 'oklch').
channelstring'h'Channel to control (e.g. 'h', 's', 'l', 'alpha').
disabledbooleanfalseDisables interaction.
dir'ltr' | 'rtl'Reading direction. Read from context by the parts, but not forwarded to the underlying Base UI slider, wrap the tree in Base UI's DirectionProvider to change the slider's own direction.
invertedbooleanfalseMirrors the gradient's color ramp. Only ColorSlider.Gradient reads it; the track, range and thumb positions are Base UI's and are unaffected.
orientation'horizontal' | 'vertical''horizontal'Slider orientation.
onValueChange(color: Color) => voidCalled on every value change, including mid-drag.
onValueCommit(color: Color) => voidCalled when a change-producing interaction ends.
classNamestringClass applied to the rendered element.
styleReact.CSSPropertiesInline styles applied to the rendered element.
childrenReact.ReactNodeThe slider's parts.

The stepping interval is not a prop. It comes from the channel's own config, resolved from colorSpace and channel. The alpha channel is handled separately and ranges over 0100.

ColorSlider.Control

The pointer-interaction area, rendering Base UI's Slider.Control. It is not optional: Base UI handles pointerdown here, so ColorSlider.Track must be nested inside it for dragging to work.

Extends ComponentPropsWithoutRef<"div">; it declares no props of its own.

ColorSlider.Track

The rail the thumb travels along, rendering Base UI's Slider.Track.

Extends ComponentPropsWithoutRef<"div">; it declares no props of its own.

ColorSlider.Range

The filled portion of the track, rendering Base UI's Slider.Indicator.

Extends ComponentPropsWithoutRef<"div">; it declares no props of its own.

ColorSlider.Gradient

Renders the slider's color ramp as a <canvas> inside a <span> wrapper. The transparency checkerboard is the wrapper's own CSS background, which the canvas composites over, so no separate part is needed for it.

Extends ComponentPropsWithoutRef<"span">.

PropTypeDefaultDescription
colorsstring[]AutoExplicit color stops. When omitted, 12 stops are computed from the slider's channel and current color. At least two valid stops are required, or nothing is painted.
anglenumberAutoRotation in degrees. 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 the current color, including alpha.
classNamestringClass applied to the wrapper element.
styleReact.CSSPropertiesInline styles merged over the wrapper's checkerboard background.

ColorSlider.Checkerboard deprecated

Deprecated

ColorSlider.Gradient 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. Renders a <div> and extends ComponentPropsWithoutRef<"div">.

ColorSlider.Thumb

The draggable handle, rendering Base UI's Slider.Thumb: a <div> with a nested <input type="range">. The input is the focusable control and carries the slider semantics; the outer <div> takes tabIndex={-1}.

Extends ComponentPropsWithoutRef<"div">; it declares no props of its own. aria-label passes through to the nested input.

TIP

Unlike the Vue, Svelte and Angular packages, the React thumb does not generate an aria-label from the channel name. Pass one yourself, <ColorSlider.Thumb aria-label="Hue" />, or the input is announced with Base UI's default value text alone.

Data Attributes

Base UI applies these to the parts it renders.

AttributePartPresent when
data-orientationRoot, Control, Track, Range, ThumbAlways; the value is horizontal or vertical.
data-disabledRoot, Control, Track, Range, ThumbThe root is disabled.
data-draggingRoot, Control, Track, Range, ThumbA pointer drag is in flight.

ColorSlider.Gradient is a plain element and carries no state attributes; style it 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 exposes a single focusable control for the channel it drives, provided by Base UI's slider: a visually hidden <input type="range"> inside the thumb.

ARIA Labels

AttributeDescription
role="slider"Implicit, from the nested <input type="range">.
aria-labelNot generated. Pass aria-label on ColorSlider.Thumb to name the channel.
aria-valuemin / aria-valuemaxThe channel's range in display units, taken from the min and max on the input.
aria-valuenowThe current channel value in display units.
aria-valuetextBase UI's locale-formatted value.
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 Base UI's large step, which is 10 display units
Page Up / Page DownIncrease / decrease by the same large step
HomeMove to the channel minimum
EndMove to the channel maximum

The large step is a fixed amount of 10, not ten times the channel step. onValueCommit fires once at the end of an interaction, never on every repeat.