Color picker
A color field with a saturation and brightness area, hue, opacity, a palette and an eyedropper. The
<input type="text"> still holds the value and the name, so the form posts
#4f46e5 and the hex stays typeable for anyone who already knows the color.
Examples
The same <input> with different attributes. The panel opens from the swatch to the left of the field.
No value
A field with no value starts empty, with no color picked for you, and required
blocks the submit until someone picks one.
No opacity
data-alpha="false" removes the track; the hex always has 6 digits.
hsl format
data-format="hsl"; with opacity below 1, it writes hsla().
Custom palette
data-swatches with the brand colors, separated by commas.
No palette
data-swatches="false" leaves only the area, the tracks and the value.
Panel aligned to the left
data-placement="bottom-start". The screen edges still have the final say.
With an error
aria-invalid="true" on the field — Django 5 already writes it.
This color is already used by another badge.
How to use
Mark the <input> and it initializes on its own at page load and on every htmx:afterSwap.
The swatch and the value become a single control, .tuc-color-field, with the height, radius and focus ring of the Select.
<input type="text" name="color" value="#4f46e5" data-tuc-color>
<input type="text" name="brand" value="#0d9488" data-tuc-color data-alpha="false"
data-swatches="#0a0a0a,#ea580c,#16a34a">| Attribute | Default | What for |
|---|---|---|
data-format | hex | Value format: hex, rgb or hsl |
data-alpha | true | false removes the opacity track |
data-swatches | 15-color palette | Comma-separated colors, or false to hide it |
data-placement | bottom-center | Side and alignment of the panel |
In a Django form
class TagForm(forms.ModelForm):
class Meta:
model = Tag
fields = ["name", "color"]
widgets = {
"color": forms.TextInput(attrs={"data-tuc-color": "", "data-alpha": "false"}),
}A CharField(max_length=9) fits the hex with opacity (#rrggbbaa). Use TextInput, not
type="color": the native one has no opacity and would change how the field looks.
In JavaScript
const c = new Tucano.ColorPicker('#color', {
format: 'rgb',
alpha: false,
swatches: ['#0a0a0a', '#ea580c', '#16a34a'],
onChange: (value, { rgb, hsva }) => console.log(value),
});
c.getValue(); // 'rgb(79, 70, 229)'
c.getRgb(); // { r: 79, g: 70, b: 229, a: 1 }
c.setValue('#16a34a'); // true; text that is not a color returns false and nothing changes
c.open();
c.destroy();Value and form
What posts is your own <input>, with its name. The text stays in the requested format,
whatever notation was used to pick the color.
| Format | Opaque | With opacity |
|---|---|---|
hex | #4f46e5 | #4f46e599 |
rgb | rgb(79, 70, 229) | rgba(79, 70, 229, 0.6) |
hsl | hsl(243, 75%, 59%) | hsla(243, 75%, 59%, 0.6) |
As input, typed in the field or in the panel, it accepts #rgb, #rgba, #rrggbb, #rrggbbaa,
rgb(), rgba(), hsl() and hsla(). The text is read on commit — leaving the field or
Enter — and text that is not a color reverts to the current value, instead of clearing the color.
Every change fires tucano:change on the field, plus the native change, for validation and HTMX.
While dragging, as with <input type="range">, tucano:change fires on every move and the
native change only once, on release.
document.querySelector('#color').addEventListener('tucano:change', (e) => {
e.detail.value; // '#4f46e5'
e.detail.rgb; // { r, g, b, a }
e.detail.hsva; // { h, s, v, a }
e.detail.instance; // the ColorPicker
});To choose light or dark text on top of the color the person picked, Tucano.color.isDark('#4f46e5') returns
true — it measures luminance, not the average of the channels, which is not how the eye reads it.
The stored state is HSVA, not RGB
Converting to RGB on every move loses the hue when saturation reaches zero: every gray would turn red when brightened again. By keeping hue, saturation, brightness and opacity, dragging to white and back returns the color you started from.
Eyedropper only where the browser has one
The button that captures a color from the screen appears when the EyeDropper API exists — today, Chrome and Edge on desktop. Elsewhere it is simply not drawn, instead of sitting there doing nothing.
Keyboard and accessibility
The trigger is the swatch next to the field, a real <button>, with
aria-haspopup="dialog" and aria-expanded. Arriving with Tab opens nothing.
| Key | Where | Action |
|---|---|---|
Enter Space | Swatch | Opens the panel with focus on the area, and closes it |
↓ | Swatch or field | Opens the panel with focus on the area |
← → | Area | Saturation, in 2% steps; with Shift, in steps of 10 |
↑ ↓ | Area | Brightness, in the same steps |
← → ↑ ↓ | Tracks | Hue by 1 degree, opacity by 1%; with Shift, in steps of 10 |
Home End | Tracks | Minimum and maximum |
Esc | Panel | Closes and returns focus to the swatch |
Why focusing the field does not open the panel
Opening on focus got in the way twice: the panel popped up just from tabbing through the form, and it covered the field itself for anyone who wanted to type the hex. That is why the trigger is the swatch, which already responds to Enter and Space because it is a button, and the field only gets the down arrow, the same as the date picker.
The tracks are role="slider" with aria-valuenow, the area is labelled "Saturação e brilho" (saturation and brightness — the default labels are in Portuguese, and Tucano.setTexts({ colorpicker }) replaces them), and the value in the
panel is a regular .tuc-input. The panel closes when focus leaves it or on an outside click; with
prefers-reduced-motion, it only fades.
API
Generated from the code on every build — if something is not here, it does not exist.
[data-tuc-color]new Tucano.ColorPicker(alvo, opcoes)data-alpha data-format data-placement data-swatchesgetValue getRgb setValue open close toggle destroytucano:changeOptions
The notes in this table come from comments in the source code, which are written in Portuguese.
| Option | Default | What for |
|---|---|---|
format | 'hex' | 'hex' | 'rgb' | 'hsl' |
alpha | true | |
swatches | PALETTE | false desliga |
placement | 'bottom-center' | mesma regra do date picker: centralizado, preso na borda da tela |
appendTo | undefined | |
onChange | null |