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 | null—Controlled 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) => void—Called on every value change, including mid-drag.
onValueCommit(color: Color) => void—Called when a change-producing interaction ends.
classNamestring—Class applied to the rendered element.
styleReact.CSSProperties—Inline styles applied to the rendered element.
childrenReact.ReactNode—The 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.
classNamestring—Class applied to the wrapper element.
styleReact.CSSProperties—Inline 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.
classNamestring—Class applied to the rendered element.
styleReact.CSSProperties—Inline 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.