Color Picker
stimeo--color-picker
Per-channel sliders and a hex input that stay in two-way sync.
The stimeo--color-picker controller decomposes color selection into independent WAI-ARIA Slider channels (hue, saturation, lightness, and an optional alpha) rather than a 2-D palette, so every adjustment is keyboard- and screen-reader-operable (WCAG 1.4.1: not by color alone). Each slider carries aria-valuenow and a human-readable aria-valuetext like "Hue 210 degrees"; the hex input stays two-way synced and normalized; the current color is published on --stimeo-color (here driving the preview swatch) and mirrored into a hidden form field. Pointer-drag listeners are bound to an AbortController and released on drag end and on disconnect (Turbo navigation included), and the color is fully reconstructable from the hex value. Behavior only — the swatch and track visuals are this Playground's CSS (the thumb positions come from a small consumer script).
Keyboard
| Key | Action |
|---|---|
| ↑ / → | Increase the focused channel by one step. |
| ↓ / ← | Decrease the focused channel by one step. |
| 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. %>
<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 %>"
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 --stimeo-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;
color: #475569;
}
.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(--stimeo-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;
color: #475569;
}
.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 event, we normalize each
// slider's aria-valuenow to a fraction and reflect it as --stimeo-demo-pos for the
// thumb position (an example of consumer-side styling).
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('--stimeo-demo-pos', `${fraction * 100}%`);
});
};
picker.addEventListener('stimeo--color-picker:change', 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. |
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
|
Initial hex color seeding the model (default #000000). |
data-stimeo--color-picker-value-value |
alpha
|
Whether the alpha channel is enabled (default false; when off color stays opaque). | data-stimeo--color-picker-alpha-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 pointer drag on a channel slider and tracks movement to set its value. | stimeo--color-picker#onPointerDown |
Events
| Name | Description | Event |
|---|---|---|
change
|
Fires whenever the color changes; detail { value, rgba } (hex string plus { r, g, b, a }). |
stimeo--color-picker:change |
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 (e.g. "Hue 210 degrees"). |
value |
Hex input / field | The current color as a hex string. |
--stimeo-color |
Preview / root | The current color, for the preview swatch. |