Color Picker
stimeo--color-picker
Colour chosen on separate hue, saturation and lightness sliders, tied to a hex field.
Splits choosing a colour into separate hue, saturation and lightness sliders instead of a two-dimensional palette. Because they are separate, every adjustment works from the keyboard and the current value reaches screen readers, spoken as "hue 210 degrees" rather than a bare number. The hex field is tied to them both ways, so touching either keeps them in step. The chosen colour is readable from CSS for a swatch, and mirrored into a hidden form field. A second finger landing mid-drag neither steers the value nor cuts the drag short. The colour can be rebuilt from the hex value alone, so going back in the browser returns the colour you picked rather than the original one. The swatches and tracks are drawn by this demo's CSS.
Keyboard
| Key | Action |
|---|---|
| ↑ / → | Increase the focused channel by one step. With logicalTrack under RTL the horizontal pair swaps. |
| ↓ / ← | Decrease the focused channel by one step. With logicalTrack under RTL the horizontal pair swaps. |
| PageUp / PageDown | Increase / decrease by a larger step. |
| Home / End | Jump the channel to its minimum / maximum. |
<%# Markup for the color-picker demo.
The library manages each channel's (hue / saturation / lightness) role="slider"
value, composes them into a color two-way-synced with the hex input, and updates
--stimeo--color for the preview. The 2D palette is decomposed into independent 1D
sliders for accessibility. Thumb position and look are the consumer's CSS.
data-value-text carries the announced wording, so this catalog announces the
channel in the reader's own language instead of the library's English. %>
<div
class="color-picker"
data-controller="stimeo--color-picker"
data-stimeo--color-picker-value-value="#3366cc">
<div
class="color-picker__preview"
data-stimeo--color-picker-target="preview"
aria-hidden="true"></div>
<div class="color-picker__channels">
<% { hue: [t("components.color_picker.demo.hue"), 360, 210],
saturation: [t("components.color_picker.demo.saturation"), 100, 60],
lightness: [t("components.color_picker.demo.lightness"), 100, 50] }
.each do |channel, (label, max, now)| %>
<div class="color-picker__channel">
<span class="color-picker__channel-label"><%= label %></span>
<div
class="color-picker__track color-picker__track--<%= channel %>"
role="slider"
aria-label="<%= label %>"
data-channel="<%= channel %>"
data-value-text="<%= t("components.color_picker.demo.value_text.#{channel}") %>"
tabindex="0"
aria-valuemin="0"
aria-valuemax="<%= max %>"
aria-valuenow="<%= now %>"
data-stimeo--color-picker-target="slider"
data-action="
keydown->stimeo--color-picker#onKeydown
pointerdown->stimeo--color-picker#onPointerDown">
<span class="color-picker__thumb" aria-hidden="true"></span>
</div>
</div>
<% end %>
</div>
<label class="color-picker__hex">
<span><%= t("components.color_picker.demo.hex") %></span>
<input
type="text"
inputmode="text"
aria-label="<%= t("components.color_picker.demo.hex") %>"
value="#3366cc"
data-stimeo--color-picker-target="hex"
data-action="change->stimeo--color-picker#onHexInput" />
</label>
<input type="hidden" name="brand_color" data-stimeo--color-picker-target="field" />
</div>
/*
* Presentation-only styles for the color-picker demo.
* The library updates each slider's aria-valuenow / aria-valuetext, the hex value,
* and --stimeo--color for the preview. The thumb position uses --demo-pos,
* which demo.js normalizes from aria-valuenow (an example of consumer-side styling).
*/
.color-picker {
display: grid;
gap: 0.85rem;
max-width: 20rem;
}
.color-picker__preview {
height: 3rem;
border: 1px solid #cbd5e1;
border-radius: 0.5rem;
/* Show the current color the library exposes. */
background: var(--stimeo--color, #000);
}
.color-picker__channels {
display: grid;
gap: 0.7rem;
}
.color-picker__channel {
display: grid;
gap: 0.25rem;
}
.color-picker__channel-label {
font-size: 0.8rem;
/* Was a hard-coded slate-600, which does not follow the theme: on the dark
canvas it measures 2.30:1. The muted token is theme-aware and clears AA. */
color: var(--color-text-muted);
}
.color-picker__track {
position: relative;
height: 0.85rem;
border-radius: 0.5rem;
cursor: pointer;
touch-action: none;
}
.color-picker__track--hue {
background: linear-gradient(
to right,
#f00 0%,
#ff0 17%,
#0f0 33%,
#0ff 50%,
#00f 67%,
#f0f 83%,
#f00 100%
);
}
.color-picker__track--saturation {
background: linear-gradient(to right, #808080, #2563eb);
}
.color-picker__track--lightness {
background: linear-gradient(to right, #000, #2563eb, #fff);
}
.color-picker__thumb {
position: absolute;
top: 50%;
/* The fraction the demo's demo.js computed from aria-valuenow. */
left: var(--demo-pos, 0%);
width: 0.95rem;
height: 0.95rem;
transform: translate(-50%, -50%);
border: 2px solid #fff;
border-radius: 50%;
box-shadow: 0 0 0 1px #334155;
background: transparent;
}
.color-picker__track:focus-visible {
outline: 2px solid var(--accent, #2563eb);
outline-offset: 3px;
}
.color-picker__hex {
display: grid;
gap: 0.25rem;
font-size: 0.8rem;
/* Theme-aware, for the same reason as the channel label above. */
color: var(--color-text-muted);
}
.color-picker__hex input {
padding: 0.4rem 0.55rem;
border: 1px solid #cbd5e1;
border-radius: 0.4rem;
font: inherit;
font-family: ui-monospace, monospace;
}
// Demo that consumes the color-picker's values (consumer-side JS).
//
// The core controller (stimeo--color-picker) updates each slider's aria-valuenow /
// aria-valuetext, the hex value, and --stimeo--color for the preview, but does not
// provide the thumb position (the look). Here, on each change and reconcile event, we
// normalize each slider's aria-valuenow to a fraction and reflect it as --demo-pos for
// the thumb position (an example of consumer-side styling). Both events are needed:
// change reports what the reader did, reconcile a color the controller settled on
// itself (a runtime value or alpha change), and the thumbs must follow either one.
document.querySelectorAll('[data-controller~="stimeo--color-picker"]').forEach((picker) => {
const sliders = picker.querySelectorAll('[role="slider"]');
const positionThumbs = () => {
sliders.forEach((slider) => {
const now = Number(slider.getAttribute('aria-valuenow'));
const max = Number(slider.getAttribute('aria-valuemax')) || 1;
const min = Number(slider.getAttribute('aria-valuemin')) || 0;
const fraction = max > min ? (now - min) / (max - min) : 0;
slider.style.setProperty('--demo-pos', `${fraction * 100}%`);
});
};
picker.addEventListener('stimeo--color-picker:change', positionThumbs);
picker.addEventListener('stimeo--color-picker:reconcile', positionThumbs);
positionThumbs();
});
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--color-picker"
Targets
| Name | Description | Attribute |
|---|---|---|
slider
required
|
A channel slider (role=slider) for hue/saturation/lightness/alpha, identified by data-channel. An optional data-value-text template ({value} is substituted) sets the announced wording. |
data-stimeo--color-picker-target="slider" |
hex
|
Text input holding the hex color, two-way synced with the channels. | data-stimeo--color-picker-target="hex" |
preview
|
Swatch element receiving the current color via the --stimeo--color custom property. |
data-stimeo--color-picker-target="preview" |
field
|
Hidden input mirroring the current hex color for form submission. | data-stimeo--color-picker-target="field" |
Values
| Name | Description | Attribute |
|---|---|---|
value
|
The current hex color (default #000000). The controller writes every committed color back here, so a Turbo restore and a form submission both carry the picked color; writing it from outside re-seeds the model and fires reconcile. |
data-stimeo--color-picker-value-value |
alpha
|
Whether the alpha channel is enabled (default false; when off the color stays opaque and an alpha slider cannot be edited). | data-stimeo--color-picker-alpha-value |
logicalTrack
|
Whether the track is laid out with logical CSS and mirrors under RTL (default false). When set, the pointer mapping and the horizontal arrow pair follow the writing direction; left unset nothing reads direction. | data-stimeo--color-picker-logical-track-value |
Actions
| Name | Description | Action |
|---|---|---|
onHexInput
|
Parses the hex input on confirm and syncs every channel and surface, restoring the last valid value on invalid input. | stimeo--color-picker#onHexInput |
onKeydown
|
Steps the focused channel slider per the APG Slider pattern (arrows, PageUp/Down, Home/End). | stimeo--color-picker#onKeydown |
onPointerDown
|
Begins a primary-button drag on a channel slider, owned by the pointer that started it, and tracks its movement to set the value. | stimeo--color-picker#onPointerDown |
Events
| Name | Description | Event |
|---|---|---|
change
|
Fires when the reader changes the color; detail { value, rgba } (hex string plus { r, g, b, a }). |
stimeo--color-picker:change |
reconcile
|
Fires when changing alpha or value at runtime moves the committed color; same detail as change. | stimeo--color-picker:reconcile |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
aria-valuenow |
Slider | The current value of each channel. |
aria-valuetext |
Slider | Human-readable value — the `data-value-text` wording when supplied, else e.g. "Hue 210 degrees". |
aria-valuemin / aria-valuemax |
Slider | The resolved channel range: the authored bounds, or the channel default when they are omitted. |
value |
Hex input / field | The current color as a hex string. |
--stimeo--color |
Preview / root | The current color, for the preview swatch. |