Skip to content

ColorWheel

A circular 2D area component for adjusting two color channels mapped to angle and radius.

Preview

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

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

  return (
    <>
      <code>{hex}</code>
      <ColorWheel.Root
        value={color}
        onValueChange={setColor}
        colorSpace="hsl"
        channelAngle="h"
        channelRadius="s"
        className="relative block size-64 overflow-hidden rounded-full"
        style={{ containerType: "inline-size" }}
      >
        <ColorWheel.Gradient className="absolute inset-0 block" />
        <ColorWheel.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)]
          "
        />
      </ColorWheel.Root>
    </>
  );
}

Anatomy

tsx
<ColorWheel.Root>
  <ColorWheel.Gradient />
  <ColorWheel.Thumb />
</ColorWheel.Root>

Examples

HSL / Hue x Saturation

HSL color wheel with Hue mapped to angle and Saturation to radius.

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

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

  return (
    <>
      <code>{hex}</code>
      <ColorWheel.Root
        value={color}
        onValueChange={setColor}
        colorSpace="hsl"
        channelAngle="h"
        channelRadius="s"
        className="relative block size-64 overflow-hidden rounded-full"
        style={{ containerType: "inline-size" }}
      >
        <ColorWheel.Gradient className="absolute inset-0 block" />
        <ColorWheel.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)]
          "
        />
      </ColorWheel.Root>
    </>
  );
}

HSL / Hue x Lightness

HSL color wheel with Hue mapped to angle and Lightness to radius.

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

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

  return (
    <>
      <code>{hex}</code>
      <ColorWheel.Root
        value={color}
        onValueChange={setColor}
        colorSpace="hsl"
        channelAngle="h"
        channelRadius="l"
        className="relative block size-64 overflow-hidden rounded-full"
        style={{ containerType: "inline-size" }}
      >
        <ColorWheel.Gradient className="absolute inset-0 block" />
        <ColorWheel.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)]
          "
        />
      </ColorWheel.Root>
    </>
  );
}

OKLCh / Hue x Chroma

OKLCh color wheel with Hue mapped to angle and Chroma to radius.

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

export default function ColorWheelOKLCh() {
  const { color, setColor, hex } = useColor("oklch(0.6 0.15 210)");

  return (
    <>
      <code>{hex}</code>
      <ColorWheel.Root
        value={color}
        onValueChange={setColor}
        colorSpace="oklch"
        channelAngle="h"
        channelRadius="c"
        className="relative block size-64 overflow-hidden rounded-full"
        style={{ containerType: "inline-size" }}
      >
        <ColorWheel.Gradient className="absolute inset-0 block" />
        <ColorWheel.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)]
          "
        />
      </ColorWheel.Root>
    </>
  );
}

API Reference

Every part is also exported unnamespaced, ColorWheelRoot, ColorWheelGradient, ColorWheelThumb, alongside the ColorWheel.* namespace. The root's context is readable with useColorWheelContext().

ColorWheel.Root

The root container that manages wheel state and color channel binding. Renders a <div> and owns the pointer and keyboard interaction for the whole family.

PropTypeDefaultDescription
valueColor | string | nullControlled color value.
defaultValueColor | string'hsl(0, 100%, 50%)'Initial color when uncontrolled.
colorSpaceSpaceId'hsl'Color space (e.g. 'hsl', 'oklch').
channelAnglestringAutoChannel mapped to the angle axis. Defaults to the color space's first channel.
channelRadiusstringAutoChannel mapped to the radius axis. Defaults to the color space's second channel.
startAnglenumber0Starting angle offset in degrees. 0 puts the angle axis origin at 12 o'clock.
disabledbooleanfalseDisables interaction.
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 wheel's parts.

TIP

channelAngle and channelRadius are the React spelling. Vue, Svelte and Angular name the same two props angleChannel and radiusChannel.

ColorWheel.Gradient

Renders a polar gradient canvas for the wheel, sampled from the root's color space and channel configuration. The transparency checkerboard is this element's own CSS background, so no separate part is needed for it.

Extends ComponentPropsWithoutRef<"span">.

PropTypeDefaultDescription
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 background and border-radius.

ColorWheel.Checkerboard deprecated

Deprecated

ColorWheel.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">.

ColorWheel.Thumb

The single combined handle, and the wheel's only focusable element. One thumb drives both axes: it renders role="slider", takes tabIndex={0} unless the root is disabled, and is positioned in polar coordinates from the angle and radius channel values.

Because one handle serves two channels, it announces both: aria-label names the channel pair and aria-valuetext carries both formatted values. There is no separate thumb per axis.

Extends ComponentPropsWithoutRef<"span">.

PropTypeDefaultDescription
aria-labelstringChannel pairOverrides the generated "Hue, Saturation" label.
classNamestringClass applied to the rendered element.
styleReact.CSSPropertiesInline styles merged over the computed polar transform.

Data Attributes

AttributePartPresent when
data-disabledRoot, Gradient, ThumbThe root is disabled.

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

ColorWheel exposes a single focusable thumb that drives both the angle and the radius channel. Keyboard events are handled on the root, which sees them bubble up from the focused thumb.

ARIA Labels

AttributeDescription
role="slider"Applied to ColorWheel.Thumb, with aria-roledescription="Color thumb".
aria-labelDefaults to the angle and radius channel labels, e.g. "Hue, Saturation". Pass your own aria-label on the thumb to override.
aria-valuemin / aria-valuemaxThe angle channel's range.
aria-valuenowThe current angle channel value. Only one number can be carried here, so the angle axis owns it.
aria-valuetextBoth channels formatted, e.g. "Hue 210°, Saturation 80%".
aria-disabledApplied to the root and the thumb when disabled is set.

Keyboard Navigation

KeyAction
Arrow RightIncrease angle by one step
Arrow LeftDecrease angle by one step
Arrow UpIncrease radius by one step
Arrow DownDecrease radius by one step
Shift + ArrowMove by 10 steps
Page Up / Page DownIncrease / decrease radius by 10 steps (unaffected by Shift)
HomeMove both angle and radius to their minimum
EndMove both angle and radius to their maximum

When the angle channel is cyclic (a degree-formatted channel such as hue), stepping past the end wraps around instead of clamping.