Skip to content

ColorTriangle

A triangular 2D area component for adjusting two color channels, or three, as barycentric coordinates on a simplex.

Preview

svelte
<script lang="ts">
  import { ColorTriangle, useColor } from "@urcolor/svelte";

  const colorState = useColor("hsl(210, 80%, 50%)");
</script>

<ColorTriangle.Root
  bind:value={() => colorState.color, colorState.setColor}
  colorSpace="hsv"
  xChannel="s"
  yChannel="v"
  class="relative block size-64"
  style="container-type: inline-size"
>
  <ColorTriangle.Gradient class="absolute inset-0 block" />
  <ColorTriangle.Thumb class="size-4 rounded-full border-2 border-white" />
</ColorTriangle.Root>

useColor returns an object whose color, hex and alpha members are getters, not refs. Keep the object rather than destructuring it, and bind with Svelte 5's function form, bind:value={() => colorState.color, colorState.setColor}, which pairs the getter with the setter.

Anatomy

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

Examples

HSV / Saturation x Brightness

HSV triangle with Saturation and Brightness on the two axes. Both channels are the color space's defaults, so they can be omitted.

svelte
<script lang="ts">
  import { ColorTriangle, useColor } from "@urcolor/svelte";

  const colorState = useColor("hsl(210, 80%, 50%)");
</script>

<ColorTriangle.Root
  bind:value={() => colorState.color, colorState.setColor}
  colorSpace="hsv"
  xChannel="s"
  yChannel="v"
  class="relative block size-64"
  style="container-type: inline-size"
>
  <ColorTriangle.Gradient class="absolute inset-0 block" />
  <ColorTriangle.Thumb class="size-4 rounded-full border-2 border-white" />
</ColorTriangle.Root>

HSL / Saturation x Lightness

The same two axes in HSL, where the second channel is Lightness.

svelte
<ColorTriangle.Root
  bind:value={() => colorState.color, colorState.setColor}
  colorSpace="hsl"
  xChannel="s"
  yChannel="l"
  class="relative block size-64"
  style="container-type: inline-size"
>
  <ColorTriangle.Gradient class="absolute inset-0 block" />
  <ColorTriangle.Thumb class="size-4 rounded-full border-2 border-white" />
</ColorTriangle.Root>

Maxwell's RGB triangle

Supplying zChannel switches the triangle from a two-channel half-simplex to a full three-channel barycentric simplex. One thumb still drives all three.

svelte
<ColorTriangle.Root
  bind:value={() => colorState.color, colorState.setColor}
  colorSpace="srgb"
  xChannel="r"
  yChannel="g"
  zChannel="b"
  class="relative block size-64"
  style="container-type: inline-size"
>
  <ColorTriangle.Gradient class="absolute inset-0 block" />
  <ColorTriangle.Thumb class="size-4 rounded-full border-2 border-white" />
</ColorTriangle.Root>

The first keypress "jumps"

In three-channel mode the three values are barycentric coordinates: only the ratio between them is meaningful, so the root renormalizes them onto the simplex (u + v + w === 1) on every write. An srgb color that starts off the simplex is rewritten onto it by the first arrow press. This is inherent to the geometry, not a bug, afterwards the values stay on the simplex and step smoothly.

Rotation and mirroring

inverted swaps the second and third vertices, mirroring the outline. Rotate the triangle with a CSS transform on the root. The root maps pointer positions back through its own transform, so dragging still follows the corner each vertex points at.

svelte
<ColorTriangle.Root
  bind:value={() => colorState.color, colorState.setColor}
  style="transform: rotate(180deg)"
  inverted
  class="relative block size-64"
>
  <ColorTriangle.Gradient class="absolute inset-0 block" />
  <ColorTriangle.Thumb class="size-4 rounded-full border-2 border-white" />
</ColorTriangle.Root>

Keeping the thumb inside the outline

thumbAlignment="contain" positions the thumb against a triangle inset by half the thumb's own size, so its box never crosses an edge. The pointer maps onto the same inset triangle, so cursor and thumb still agree at the corners.

svelte
<ColorTriangle.Root
  bind:value={() => colorState.color, colorState.setColor}
  thumbAlignment="contain"
  class="relative block size-64"
>
  <ColorTriangle.Gradient class="absolute inset-0 block" />
  <ColorTriangle.Thumb class="size-4 rounded-full border-2 border-white" />
</ColorTriangle.Root>

Render delegation

Every part accepts a child snippet that replaces the element it would have rendered. The snippet receives the props the part built, including its behaviour attachment, so spreading them is what keeps the part working.

svelte
<ColorTriangle.Root bind:value={() => colorState.color, colorState.setColor}>
  {#snippet child({ props })}
    <section {...props}>
      <ColorTriangle.Gradient />
      <ColorTriangle.Thumb>
        {#snippet child({ props })}
          <button {...props}></button>
        {/snippet}
      </ColorTriangle.Thumb>
    </section>
  {/snippet}
</ColorTriangle.Root>

API Reference

Every part is also exported unnamespaced, ColorTriangleRoot, ColorTriangleGradient, ColorTriangleThumb, alongside the ColorTriangle.* namespace. The root's context is readable with colorTriangleContext.get().

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.

Extends HTMLAttributes<HTMLDivElement>.

PropTypeDefaultDescription
valueColor | string | nullThe color value. Bindable with bind:value.
defaultValueColor | string | nullThe color used until the first interaction when value is not bound. Falls back to hsl(0, 100%, 50%).
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 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.
disabledbooleanfalsePrevents the user from interacting with the triangle.
onValueChange(color: Color) => voidCalled on every change, including mid-drag.
onValueCommit(color: Color) => voidCalled once at the end of an interaction.
classstringClass applied to the rendered element.
childSnippet<[ChildSnippetArgs]>Replaces the default element; receives the props it would have received.

TIP

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, there is no Checkerboard part in this package.

Extends HTMLAttributes<HTMLSpanElement>.

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.
classstringClass applied to the wrapper element.
childSnippet<[ChildSnippetArgs]>Replaces the default <canvas>, not the wrapper; receives the canvas props, including the paint attachment. The checkerboard wrapper is always rendered by this part.

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

Extends HTMLAttributes<HTMLSpanElement>.

PropTypeDefaultDescription
aria-labelstringChannel setOverrides the generated "Saturation, Brightness" label.
classstringClass applied to the rendered element.
childSnippet<[ChildSnippetArgs]>Replaces the default element; receives the props it would have received.

The thumb registers itself with the root so the "contain" inset can be measured against it.

Data Attributes

AttributePartPresent when
data-color-triangle-rootRootAlways. Marks the root for descendants and for styling.
data-disabledRoot, Gradient, ThumbThe root is disabled.
data-draggingRoot, ThumbA pointer drag is in flight.

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

KeyAction
Arrow RightIncrease the first channel by one step
Arrow LeftDecrease the first channel by one step
Arrow UpIncrease the second channel by one step
Arrow DownDecrease the second channel by one step
Shift + ArrowMove by 10 steps
Page Up / Page DownIncrease / decrease the second channel by 10 steps (unaffected by Shift)
HomeMove both channels to their minimum
EndMove both channels to their maximum

There is no third-axis key. In three-channel mode the zChannel value falls out of the barycentric renormalization of the other two, so the same four arrows cover the whole simplex.

In two-channel mode 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. onValueCommit fires once on key release, not on every repeat.