Skip to content

ColorTriangle

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

Preview

#1980e6
Source code
vue
<script setup lang="ts">
import {
  ColorTriangleRoot,
  ColorTriangleGradient,
  ColorTriangleThumb,
  useColor,
} from "@urcolor/vue";

const { color, hex } = useColor("hsl(210, 80%, 50%)");
</script>

<template>
  <code>{{ hex }}</code>
  <ColorTriangleRoot
    v-model="color"
    color-space="hsv"
    x-channel="s"
    y-channel="v"
    class="relative block size-64"
  >
    <ColorTriangleGradient class="absolute inset-0 block" />
    <ColorTriangleThumb
      class="
        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)]
      "
    />
  </ColorTriangleRoot>
</template>

Anatomy

vue
<template>
  <ColorTriangleRoot>
    <ColorTriangleGradient />
    <ColorTriangleThumb />
  </ColorTriangleRoot>
</template>

Examples

HSV / Saturation x Value

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

#1980e6
Source code
vue
<script setup lang="ts">
import {
  ColorTriangleRoot,
  ColorTriangleGradient,
  ColorTriangleThumb,
  useColor,
} from "@urcolor/vue";

const { color, hex } = useColor("hsl(210, 80%, 50%)");
</script>

<template>
  <code>{{ hex }}</code>
  <ColorTriangleRoot
    v-model="color"
    color-space="hsv"
    x-channel="s"
    y-channel="v"
    class="relative block size-64"
  >
    <ColorTriangleGradient class="absolute inset-0 block" />
    <ColorTriangleThumb
      class="
        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)]
      "
    />
  </ColorTriangleRoot>
</template>

HSL / Saturation x Lightness

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

#1980e6
Source code
vue
<script setup lang="ts">
import {
  ColorTriangleRoot,
  ColorTriangleGradient,
  ColorTriangleThumb,
  useColor,
} from "@urcolor/vue";

const { color, hex } = useColor("hsl(210, 80%, 50%)");
</script>

<template>
  <code>{{ hex }}</code>
  <ColorTriangleRoot
    v-model="color"
    color-space="hsl"
    x-channel="s"
    y-channel="l"
    class="relative block size-64"
  >
    <ColorTriangleGradient class="absolute inset-0 block" />
    <ColorTriangleThumb
      class="
        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)]
      "
    />
  </ColorTriangleRoot>
</template>

Maxwell's RGB Triangle

Three-channel RGB triangle using barycentric coordinates.

#1980e6
Source code
vue
<script setup lang="ts">
import {
  ColorTriangleRoot,
  ColorTriangleGradient,
  ColorTriangleThumb,
  useColor,
} from "@urcolor/vue";

const { color, hex } = useColor("hsl(210, 80%, 50%)");
</script>

<template>
  <code>{{ hex }}</code>
  <ColorTriangleRoot
    v-model="color"
    color-space="srgb"
    x-channel="r"
    y-channel="g"
    z-channel="b"
    class="relative block size-64"
  >
    <ColorTriangleGradient class="absolute inset-0 block" />
    <ColorTriangleThumb
      class="
        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)]
      "
    />
  </ColorTriangleRoot>
</template>

Three-Channel Mode

Pass z-channel to enable three-channel barycentric mode. The single ColorTriangleThumb drives all three channels.

vue
<script setup>
import {
  ColorTriangleRoot,
  ColorTriangleGradient,
  ColorTriangleThumb,
} from "@urcolor/vue";
</script>

<template>
  <ColorTriangleRoot
    v-model="color"
    color-space="srgb"
    x-channel="r"
    y-channel="g"
    z-channel="b"
  >
    <ColorTriangleGradient />
    <ColorTriangleThumb />
  </ColorTriangleRoot>
</template>

The first keypress "jumps"

In three-channel mode the three values are barycentric coordinates: only the ratio between them is meaningful, so the component renormalizes them onto the simplex (u + v + w === 1) on every write. An srgb color sitting at r/g/b 50 / 50 / 180 is rewritten to 46 / 45 / 163 the first time you press Arrow Right (which steps red by one and, as a side effect of the renormalization, pulls all three channels onto the simplex). This is inherent to the geometry, not a bug. After the first write the values stay on the simplex and step smoothly.

API Reference

Every part is a named export from @urcolor/vue. The root's context is readable with injectColorTriangleRootContext().

ColorTriangleRoot

The root container that manages triangle state and color channel binding. Clips itself to the triangle with a CSS clip-path and owns the pointer and keyboard interaction for the whole family.

PropTypeDefaultDescription
modelValueColor | string | nullControlled color value (v-model).
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. Setting it switches the triangle into barycentric three-channel mode.
orientation'vertical' | 'horizontal''vertical'Published on the root context for descendants. The geometry itself is driven by inverted alone.
invertedbooleanfalseSwap the second and third vertices, mirroring the triangle.
thumbAlignment'contain' | 'overflow''overflow'Whether the thumb is kept inside the triangle's edges.
disabledbooleanfalseDisables interaction.
dir'ltr' | 'rtl'Reading direction. Inherited from the nearest provider when omitted.
namestringHidden input name for form submission.
requiredbooleanForwarded to the hidden form input. Only meaningful alongside name.
asstring'span'The element or component to render as.
asChildbooleanfalseMerge props onto the single child instead of rendering an element.
EventPayloadDescription
update:modelValueColor | undefinedEmitted whenever the color changes.
update:colorColorMirrors update:modelValue; present for API parity.
changeColorEmitted on every value change, including mid-drag.
changeEndColorEmitted when a change-producing interaction ends.

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.

When name is set on a form control, the root also renders a visually hidden <input type="hidden"> carrying the serialized color.

ColorTriangleGradient

Renders the triangle's color surface as a <canvas> inside a wrapper element, sampled from the root's color space and channel configuration, including the third channel when one is set. A barycentric sweep has no CSS equivalent, so unlike the other gradients this always paints into a canvas and does not appear in server-rendered HTML. The transparency checkerboard is the wrapper's own CSS background, which the canvas composites over, so no separate part is needed for it.

PropTypeDefaultDescription
renderer'auto' | 'css' | 'canvas''auto'Accepted for symmetry with the other gradients. A barycentric sweep has no CSS recipe, so every value paints into a <canvas>; 'css' warns in development.
channelOverridesRecord<string, number> | false{ alpha: 1 }Lock specific channels to fixed values in the gradient. Set to false to reflect all channels from current color including alpha.
asstring'span'The element or component to render as.
asChildbooleanfalseMerge props onto the single child instead of rendering an element.

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

ColorTriangleCheckerboard deprecated

Deprecated

ColorTriangleGradient 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> by default. Place it inside ColorTriangleRoot before ColorTriangleGradient.

ColorTriangleThumb

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 handler sees the events that bubble up from here.

PropTypeDefaultDescription
aria-labelstringChannel setOverrides the generated "Saturation, Brightness" label. Passed through $attrs.
asstring'span'The element or component to render as.
asChildbooleanfalseMerge props onto the single child instead of rendering an element.

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.

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 ColorTriangleThumb, 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 X channel's range.
aria-valuenowThe current X 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

Arrow keys map to the X and Y axes, matching ColorArea.

KeyAction
Arrow Left / Arrow RightDecrease / increase the X channel by one step
Arrow Down / Arrow UpDecrease / increase the Y channel by one step
Page Down / Page UpDecrease / increase the Z channel by one step. Three-channel mode only
Shift + Arrow, Shift + PageMove by 10 steps
HomeJump to the X channel's minimum
EndJump to the X channel's maximum

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. In three-channel mode every write is renormalized onto the simplex, see Three-Channel Mode for what that means for the first keypress.

Vue's page keys differ

Vue is the only package where Page Up / Page Down drive the Z channel. React, Svelte and Angular have no third-axis key at all. The Z value falls out of the barycentric renormalization of the other two.