Tucano v0.37.2

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">
AttributeDefaultWhat for
data-formathexValue format: hex, rgb or hsl
data-alphatruefalse removes the opacity track
data-swatches15-color paletteComma-separated colors, or false to hide it
data-placementbottom-centerSide 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.

FormatOpaqueWith opacity
hex#4f46e5#4f46e599
rgbrgb(79, 70, 229)rgba(79, 70, 229, 0.6)
hslhsl(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.

KeyWhereAction
Enter SpaceSwatchOpens the panel with focus on the area, and closes it
↓Swatch or fieldOpens the panel with focus on the area
← →AreaSaturation, in 2% steps; with Shift, in steps of 10
↑ ↓AreaBrightness, in the same steps
← → ↑ ↓TracksHue by 1 degree, opacity by 1%; with Shift, in steps of 10
Home EndTracksMinimum and maximum
EscPanelCloses 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.

Markup
[data-tuc-color]
In JS
new Tucano.ColorPicker(alvo, opcoes)
Attributes
data-alpha data-format data-placement data-swatches
Methods
getValue getRgb setValue open close toggle destroy
Events
tucano:change

Options

The notes in this table come from comments in the source code, which are written in Portuguese.

OptionDefaultWhat for
format'hex''hex' | 'rgb' | 'hsl'
alphatrue
swatchesPALETTEfalse desliga
placement'bottom-center'mesma regra do date picker: centralizado, preso na borda da tela
appendToundefined
onChangenull