Skip to content

ColorTriangle

A triangular 2D area component for adjusting two (or three) color channels simultaneously.

Preview

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

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

  return (
    <>
      <code>{hex}</code>
      <ColorTriangle.Root
        value={color}
        onValueChange={setColor}
        colorSpace="hsv"
        xChannel="s"
        yChannel="v"
        className="relative block size-64"
      >
        <ColorTriangle.Gradient className="absolute inset-0 block" />
        <ColorTriangle.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)]
          "
        />
      </ColorTriangle.Root>
    </>
  );
}

Anatomy

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

Examples

HSV / Saturation x Value

HSV color triangle with Saturation and Value mapped to the triangle axes.

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

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

  return (
    <>
      <code>{hex}</code>
      <ColorTriangle.Root
        value={color}
        onValueChange={setColor}
        colorSpace="hsv"
        xChannel="s"
        yChannel="v"
        className="relative block size-64"
      >
        <ColorTriangle.Gradient className="absolute inset-0 block" />
        <ColorTriangle.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)]
          "
        />
      </ColorTriangle.Root>
    </>
  );
}

HSL / Saturation x Lightness

HSL color triangle with Saturation and Lightness mapped to the triangle axes.

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

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

  return (
    <>
      <code>{hex}</code>
      <ColorTriangle.Root
        value={color}
        onValueChange={setColor}
        colorSpace="hsl"
        xChannel="s"
        yChannel="l"
        className="relative block size-64"
      >
        <ColorTriangle.Gradient className="absolute inset-0 block" />
        <ColorTriangle.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)]
          "
        />
      </ColorTriangle.Root>
    </>
  );
}

Maxwell's RGB Triangle

Three-channel RGB triangle using barycentric coordinates.

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

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

  return (
    <>
      <code>{hex}</code>
      <ColorTriangle.Root
        value={color}
        onValueChange={setColor}
        colorSpace="srgb"
        xChannel="r"
        yChannel="g"
        zChannel="b"
        className="relative block size-64"
      >
        <ColorTriangle.Gradient className="absolute inset-0 block" />
        <ColorTriangle.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)]
          "
        />
      </ColorTriangle.Root>
    </>
  );
}

API Reference

Every part is also exported unnamespaced, ColorTriangleRoot, ColorTriangleGradient, ColorTriangleThumb, alongside the ColorTriangle.* namespace. Unlike ColorWheel, this family does not export its context hook; the root's state is reachable only through its own parts.

ColorTriangle.Root

The root container that manages triangle state and color channel binding. Renders a <div>, clips it to the triangle with a CSS clip-path, 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'hsv'Color space (e.g. 'hsv', 'hsl', 'srgb').
xChannelstringAutoThe channel mapped to the first vertex. Defaults to the color space's second channel.
yChannelstringAutoThe channel mapped to the second vertex. Defaults to the color space's third channel.
zChannelstringThe channel mapped to the third vertex. Supplying it switches the triangle to a three-channel barycentric simplex.
invertedbooleanfalseSwaps the second and third vertices, mirroring the triangle.
thumbAlignment'contain' | 'overflow''overflow'Whether the thumb is centred on the edge or kept inside it.
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 merged over the computed clipPath.
childrenReact.ReactNodeThe triangle's parts.

TIP

The root's props are an explicit list, not a DOM prop spread. It does not extend ComponentPropsWithoutRef<"div">. A pointer press that lands outside the outline is ignored: the root's box is a full square and the clip path hides the corners without stopping the event, so the root hit-tests every pointerdown against the triangle itself.

ColorTriangle.Gradient

Renders the triangle's color surface as a <canvas> inside a wrapper <span>, sampled from the root's color space and channel configuration, including the third channel when one is set. 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
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 clipPath.

Painting is skipped while a drag is in flight: a drag only moves the channels the surface already spans, so the pixels cannot change.

ColorTriangle.Checkerboard deprecated

Deprecated

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

ColorTriangle.Thumb

The single combined handle, and the triangle's only focusable element. One thumb drives every axis: it renders role="slider", takes tabIndex={0} unless the root is disabled, and is positioned from the barycentric coordinates of the channel values.

Because one handle serves two channels, or three in barycentric mode, it announces all of them: aria-label names the channel set and aria-valuetext carries every formatted value. There is no separate thumb per axis, the per-axis thumbs this family used to ship have been removed. The thumb is only a focus target and an ARIA surface; every value change is owned by the root, whose keydown handler sees the events that bubble up from here.

Extends ComponentPropsWithoutRef<"span">.

PropTypeDefaultDescription
aria-labelstringChannel setOverrides the generated "Saturation, Brightness" label.
classNamestringClass applied to the rendered element.
styleReact.CSSPropertiesInline styles merged over the computed left, top and transform.

Data Attributes

AttributePartPresent when
data-color-triangle-rootRootAlways. Marks the root for descendants and for styling.
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

ColorTriangle exposes a single focusable thumb that drives both triangle axes, and the third channel too, in barycentric mode. Keyboard events are handled on the root, which sees them bubble up from the focused thumb.

ARIA Labels

AttributeDescription
role="slider"Applied to ColorTriangle.Thumb, with aria-roledescription="Color thumb".
aria-labelDefaults to the channel labels in order, e.g. "Saturation, Brightness". Three entries when zChannel is set. Pass your own aria-label on the thumb to override.
aria-valuemin / aria-valuemaxThe first channel's range.
aria-valuenowThe current first-channel value. Only one number can be carried here, so the xChannel axis owns it.
aria-valuetextEvery active channel formatted, e.g. "Saturation 80%, Brightness 50%".
aria-disabledApplied to the root and the thumb when disabled is set.

Keyboard Navigation

Two-Channel Mode

KeyAction
Arrow RightIncrease the second channel by one step
Arrow LeftDecrease the second channel by one step
Arrow UpIncrease the first channel by one step
Arrow DownDecrease the first channel by one step
Shift + ArrowMove by 4 steps
Home / Page UpMove both channels to their maximum
End / Page DownMove both channels to their minimum

The reachable region is the half-simplex, so a step that would push the point past the hypotenuse gives way on the axis you did not drive.

Three-Channel Mode

With zChannel set, the keys move the barycentric weight of the xChannel axis and redistribute the remainder across the other two, so there is no third-axis key.

KeyAction
Arrow Up / Arrow RightIncrease the first channel's weight by 5%
Arrow Down / Arrow LeftDecrease the first channel's weight by 5%
Shift + ArrowMove by 20%
Page Up / Page DownIncrease / decrease by 20% (unaffected by Shift)
HomeJump to the first channel's vertex
EndJump to the centre, equal weight on all three

Every write is renormalized onto the simplex (u + v + w === 1), so a color that starts off it is pulled onto it by the first keypress. onValueCommit fires on each keypress rather than once on release.