ColorRing
A circular ring component for adjusting a single color channel along a circular arc.
Preview
Source code
import { ColorRing, useColor } from "@urcolor/react";
export default function ColorRingHue() {
const { color, setColor, hex } = useColor("hsl(210, 80%, 50%)");
return (
<>
<code>{hex}</code>
<ColorRing.Root
value={color}
onValueChange={setColor}
colorSpace="hsl"
channel="h"
innerRadius={0.85}
className="relative block size-64"
style={{ containerType: "inline-size" }}
>
<ColorRing.Track className="relative block size-full">
<ColorRing.Gradient className="absolute inset-0 block" />
<ColorRing.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)]
"
/>
</ColorRing.Track>
</ColorRing.Root>
</>
);
}Anatomy
<ColorRing.Root>
<ColorRing.Track>
<ColorRing.Gradient />
<ColorRing.Thumb />
</ColorRing.Track>
</ColorRing.Root>Examples
Hue
Hue ring slider for cycling through the color spectrum.
Source code
import { ColorRing, useColor } from "@urcolor/react";
export default function ColorRingHue() {
const { color, setColor, hex } = useColor("hsl(210, 80%, 50%)");
return (
<>
<code>{hex}</code>
<ColorRing.Root
value={color}
onValueChange={setColor}
colorSpace="hsl"
channel="h"
innerRadius={0.85}
className="relative block size-64"
style={{ containerType: "inline-size" }}
>
<ColorRing.Track className="relative block size-full">
<ColorRing.Gradient className="absolute inset-0 block" />
<ColorRing.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)]
"
/>
</ColorRing.Track>
</ColorRing.Root>
</>
);
}Saturation
Saturation ring slider for adjusting color intensity.
Source code
import { ColorRing, useColor } from "@urcolor/react";
export default function ColorRingSaturation() {
const { color, setColor, hex } = useColor("hsl(210, 80%, 50%)");
return (
<>
<code>{hex}</code>
<ColorRing.Root
value={color}
onValueChange={setColor}
colorSpace="hsl"
channel="s"
innerRadius={0.85}
className="relative block size-64"
style={{ containerType: "inline-size" }}
>
<ColorRing.Track className="relative block size-full">
<ColorRing.Gradient className="absolute inset-0 block" />
<ColorRing.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)]
"
/>
</ColorRing.Track>
</ColorRing.Root>
</>
);
}Alpha in the gradient
channelOverrides defaults to { alpha: 1 }, which paints the ramp fully opaque. Pass false to let the current color's alpha through. The checkerboard ColorRing.Gradient already paints is what makes it readable, so no extra element is needed.
<ColorRing.Root
value={color}
onValueChange={setColor}
colorSpace="hsl"
channel="h"
className="relative block size-64"
style={{ containerType: "inline-size" }}
>
<ColorRing.Track className="relative block size-full">
<ColorRing.Gradient channelOverrides={false} className="absolute inset-0 block" />
<ColorRing.Thumb className="size-4 rounded-full border-2 border-white" />
</ColorRing.Track>
</ColorRing.Root>API Reference
Every part is also exported unnamespaced, ColorRingRoot, ColorRingTrack, ColorRingGradient, ColorRingThumb, alongside the ColorRing.* namespace. The root's context is readable with useColorRingContext().
ColorRing.Root
The root container that manages ring state and color channel binding. Renders a <div> and owns the pointer and keyboard interaction for the whole family.
Unlike the other parts, ColorRingRootProps does not extend the DOM props of the element it renders. Only the props below are forwarded, plus ref.
| Prop | Type | Default | Description |
|---|---|---|---|
value | Color | string | null | — | Controlled color value. |
defaultValue | Color | string | 'hsl(0, 100%, 50%)' | Initial color when uncontrolled. |
colorSpace | SpaceId | 'hsl' | Color space (e.g. 'hsl', 'oklch'). |
channel | string | Auto | The channel the angle maps to. Defaults to the color space's first channel. |
startAngle | number | 0 | Degrees clockwise from 12 o'clock at which the channel's minimum sits. |
innerRadius | number | 0.7 | Hole radius as a ratio of the outer radius (0–1). Drives hit testing and the thumb's orbit. |
disabled | boolean | false | Disables interaction. |
onValueChange | (color: Color) => void | — | Called on every value change, including mid-drag. |
onValueCommit | (color: Color) => void | — | Called when a change-producing interaction ends, and on each value-changing key press. |
className | string | — | Class applied to the rendered element. |
style | React.CSSProperties | — | Inline styles applied to the rendered element. |
children | React.ReactNode | — | The ring's parts. |
TIP
channel, startAngle and innerRadius are spelled identically in React, Vue, Svelte and Angular. Unlike ColorWheel, ColorRing has no per-framework prop-name divergence.
The root must declare container-type: inline-size (or size): the thumb orbits in cqmin units, so it tracks the ring's size without measuring it.
Pointer input is only accepted inside the annulus. A press in the hole at the centre, or outside the outer edge, is ignored. The hole's size follows innerRadius.
ColorRing.Track
The annulus the thumb travels around. Sizing and positioning are yours; this part only publishes the state attributes the gradient and thumb are styled against. Renders a <div> and extends ComponentPropsWithoutRef<"div">.
ColorRing.Gradient
Paints the ring's conic color ramp, sampled from the root's color space and channel. Renders a <span> wrapper with a <canvas> inside; the wrapper carries the annulus mask, which applies to it and to every descendant, so one rasterisation cuts both the hole and the corners. The transparency checkerboard is this element's own CSS background, which the canvas bitmap composites over, so no separate part is needed for it.
Extends ComponentPropsWithoutRef<"span">.
| Prop | Type | Default | Description |
|---|---|---|---|
channelOverrides | Record<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. |
className | string | — | Class applied to the wrapper element. |
style | React.CSSProperties | — | Inline styles merged over the wrapper's background and mask. |
ColorRing.Checkerboard deprecated
Deprecated
ColorRing.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">.
ColorRing.Thumb
The handle, and the ring's only focusable element. It renders role="slider", takes tabIndex={0}, and orbits in cqmin units at the middle of the annulus, rotated to the channel's current position.
The thumb is only a focus target and an ARIA surface; every value change is owned by the root, whose keydown listener sees the events that bubble up from here. There is no aria-orientation. A ring is neither horizontal nor vertical.
Extends ComponentPropsWithoutRef<"span">.
| Prop | Type | Default | Description |
|---|---|---|---|
aria-label | string | — | Labels the slider. React does not generate one from the channel, so pass it yourself. |
className | string | — | Class applied to the rendered element. |
style | React.CSSProperties | — | Inline styles merged over the computed polar transform. |
Data Attributes
| Attribute | Part | Present when |
|---|---|---|
data-disabled | Root, Track, Gradient, Thumb | The 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:
| Variable | Default | Description |
|---|---|---|
--urcolor-checkerboard-dark | rgb(230, 230, 230) | The darker of the two checks. |
--urcolor-checkerboard-light | white | The lighter of the two checks. |
--urcolor-checkerboard-size | 16px | The 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
ColorRing exposes a single focusable thumb for the one channel the ring drives. Keyboard events are handled on the root, which sees them bubble up from the focused thumb.
ARIA Labels
| Attribute | Description |
|---|---|
role="slider" | Applied to ColorRing.Thumb. |
aria-label | Not generated by the React thumb, pass your own, e.g. aria-label="Hue". Vue, Svelte and Angular fall back to the channel's label. |
aria-valuemin / aria-valuemax | The channel's display-space range. |
aria-valuenow | The channel's current value, in display units. |
aria-disabled | Reflected on the root and the thumb; React writes "false" while enabled. |
WARNING
The React thumb keeps tabIndex={0} even when the root is disabled, so it stays in the tab order; the root still refuses pointer and keyboard input. Vue, Svelte and Angular drop the attribute instead.
Keyboard Navigation
| Key | Action |
|---|---|
| Arrow Right / Arrow Up | Increase by one step |
| Arrow Left / Arrow Down | Decrease by one step |
| Shift + Arrow | Move by 10 steps |
| Page Up | Increase by 10 steps (unaffected by Shift) |
| Page Down | Decrease by 10 steps (unaffected by Shift) |
| Home | Move to minimum |
| End | Move to maximum |
Both arrow axes drive the same angular value, so only the sign matters. When the controlled channel is cyclic (a degree-formatted channel such as hue), stepping past the end wraps around instead of clamping.