Skip to content

HabColorPicker ​

The palette in a grid, in a popover.

--hab-blue-darken-2
It hands back a token, so the pick restyles with the theme
vue
<HabColorPicker v-model="color" label="Category color" allow-clear />

Token or hex ​

By default the model is a token: --hab-blue-darken-2, not #1486c9. A stored token restyles with the theme; a stored hex is frozen at the moment someone picked it.

That is right for a color that only ever renders inside a Habitus page — and wrong for a color that is data. A token means nothing in a generated file, on a canvas, in an SVG thumbnail or in another application, because there is no Habitus stylesheet there to resolve it. For those, set format="hex":

#1486c9
In hex mode the model is a hex, for a color that is data
vue
<HabColorPicker v-model="color" format="hex" label="Project color" />

The rule: token if the color only ever renders inside a Habitus page; hex if it is stored and rendered anywhere else.

  • In hex mode the model is a hex in both directions, lower-case #rrggbb. A hex in upper case, or a token, still ticks its swatch; what comes back is always lower-case hex.
  • A hex that is on no swatch ticks nothing — but the trigger still paints it and names it by its hex, since it has no palette name. It is a stored color, often one saved by an older picker with a free hex field, and showing the placeholder would read as "no color" and invite an unseen overwrite. A replaced trigger slot gets it as hex.
  • The emit's second argument is the palette entry — { token, hex, label } — in both modes, so a caller who needs the other form never has to look it up.
  • A stored hex is a copy of the palette as it was. If a ramp is ever retuned, hexes saved before stop matching a swatch. Tokens do not have this problem, which is one more reason token is the default.

Either way the choice is one of the 65 swatches, unless you opt in to a hex field — see A custom hex. There is no spectrum and no eyedropper.

Use it for what is genuinely the user's choice — entity, category, tag color. Anything carrying meaning the system defines (status, tone, accent) is a semantic token and not a choice at all. See theming.md.

A custom hex ​

Some colors are not a choice from a palette but a fact: a corporate identity's brand color, exact to the digit. No swatch provides it. For that, and only in hex mode, allowCustom adds a hex field under the grid:

#3a7bd5
A brand color no swatch provides, typed as a hex
vue
<HabColorPicker
	v-model="color"
	format="hex"
	allow-custom
	custom-label="Custom color"
	custom-hint="A hex like #1a2b3c"
/>

A custom color will fail contrast somewhere nobody is looking

Every swatch was chosen against both themes. A typed hex was not: no theme can restyle it, and a very light or very dark one will vanish against one surface or the other — as a chip's fill, an avatar's ring, a line on a canvas. Offer the field where an exact value is genuinely required, and keep the default everywhere else.

  • Hex mode only. A typed hex is not a token, so token mode has nothing to emit: allowCustom without format="hex" warns and is ignored.
  • It accepts #rgb or #rrggbb, with or without the #, in either case, and hands back lower-case #rrggbb.
  • It commits on Enter (which also closes the sheet) or on blur, and only a valid entry that differs from the current color, so a consumer that saves on every change gets no redundant write. While the entry is invalid the field is in its error state and nothing is committed; an empty field is not an error.
  • The field shows the current color's hex, whether or not it is on a swatch. An off-palette color ticks no swatch, and the trigger paints it and names it by its hex.
  • customLabel and customHint are required and have no default: both strings are yours to translate. The hint is the field's description, and it turns error-colored while the entry is invalid, so write it as what the field accepts.
  • The field is its own tab stop, after the grid.

HabColorGrid ​

The bare grid, for when it is already inside your own sheet or panel.

teal
lime
green
red
magenta
orange
blue
purple
gray
Base
Nine ramps, lightest to darkest, plus a base row — 65 swatches

Nine ramps of seven steps, lightest to darkest, plus a base row of light and dark — 65 swatches. hues restricts and reorders them. Each row is named by its hue, by default — set :show-labels="false" for a bare grid where space is tight.

--hab-gray-darken-4 is not offered. It exists as a token but is one step past every other ramp because it is the dark UI surface value, not a color anyone should be able to pick for an entity.

The Palette as data ​

HAB_PALETTE, habPaletteFind, habPaletteRamp and habPaletteExtras are exported for validating a stored value or building color UI that is not this grid.

ts
habPaletteFind("--hab-blue-darken-2"); // → { token, hex, label }
habPaletteFind("var(--hab-teal)"); // → resolves the var() wrapper too
habPaletteFind("#1486C9"); // → the same entry: a hex resolves, in either case
habPaletteFind("#ff00ff"); // → undefined: off the ramp

undefined is the useful answer, not a failure — it is how you detect a value no theme can restyle.

The hexes are a second copy, and guarded ​

palette.ts carries the hex values theme.css already declares. That duplication is deliberate: a picker has to render swatches during SSR and in a test, where getComputedStyle cannot resolve a custom property.

tests/palette.spec.ts re-parses theme.css and fails if the two ever disagree — in both directions. Without it, editing a ramp would leave every swatch showing the old color while every component using the token showed the new one, and nothing would fail.

Anatomy ​

The sheet is a HabPopover, so it is portalled: a picker inside a bordered list, a card or a table cell is not clipped by an overflow: hidden ancestor. It closes on picking, since choosing a color is a one-shot action — set :close-on-pick="false" to compare several.

"Picking" means committing a swatch: clicking it, or pressing Enter or Space on it. A click in the gap between swatches, on a row label or on the padding does nothing. Inside the sheet the arrow keys move focus without committing — a deliberate departure from a plain radio group, where every swatch the arrows land on is chosen. The sheet is a picker, not a form group, and a consumer that saves on every change would otherwise write once per swatch crossed.

The bare HabColorGrid keeps radio-group semantics: arrowing chooses, and emits.

allowClear adds a Clear affordance for a genuinely optional color. It is not a swatch with a fill, because clearing is not a color.

Accessibility ​

  • Every swatch is named by its ramp position — "Blue darken 2" — not by its hex. The name is the only thing telling a screen-reader user which swatch is which.
  • The grid is a named radiogroup and the chosen swatch reports aria-checked. It is one tab stop; the arrows move between swatches.
  • In HabColorPicker's sheet the arrows move focus without choosing, and Enter or Space chooses. A screen reader still announces each swatch as a radio, and "checked" only for the one committed.
  • The hover scale is dropped under prefers-reduced-motion.
  • A swatch is a color with no text, so contrast rules do not apply to it — but the ring marking the chosen one is a 2px accent border, not a color change, so it is visible on light and dark swatches alike.

API ​

Props ​

PropTypeDefaultDescription
allowClearbooleanfalseAdds a Clear swatch, for a genuinely optional color.
allowCustombooleanfalseAdds a hex field under the grid, for an exact color no swatch provides — a brand color, say. Only with format="hex": a typed hex is not a token, so in token mode the prop is ignored, with a warning. Off by default, and worth keeping off where you can: a custom color is one no theme can restyle, and it will fail contrast somewhere nobody is looking.
clearLabelstringundefinedAccessible name for the Clear swatch. Required whenever allowClear is set: the swatch is a struck-through square, so this is its only name. No default — the string is the host application's to translate.
closeOnPickbooleantrueCloses the sheet when a color is committed — a click on a swatch, or Enter or Space on one. Arrowing between swatches and clicking a gap do not commit. Picking a color is a one-shot action, so this is on by default.
customHintstringundefinedHint under the hex field, saying what it accepts — #rgb or #rrggbb, with or without the #. It is the field's description, and turns error-colored while the entry is invalid, so it doubles as the error message. No default.
customLabelstringundefinedVisible label, and accessible name, of the hex field. Required whenever allowCustom is set. No default — the string is the host application's to translate.
disabledbooleanfalse—
format"token" | "hex""token"What the model holds: token if the color only ever renders inside a Habitus page, hex if it is stored and rendered anywhere else.
huesreadonly ("teal" | "lime" | "green" | "red" | "magenta" | "orange" | "blue" | "purple" | "gray")[]undefinedRestrict to some hues, in this order.
includeExtrasbooleantrueThe base row: light and dark.
labelstringundefinedAccessible name for the grid.
modelValuestring | nullundefinedThe chosen color, as a token or a hex according to format. null is "no color". Bind with v-model.
placeholderstringundefinedText on the default trigger when nothing is picked. No default — omitted means the trigger reads blank until a color is chosen.
placement"bottom-start" | "bottom" | "bottom-end" | "top" | "top-start" | "top-end" | "left" | "left-start" | "left-end" | "right" | "right-start" | "right-end""bottom-start"—
showLabelsbooleantrueHue name at the start of each row, on by default as in the design source. Off leaves a bare grid of 65 unnamed colors — fine where space is tight and the hues speak for themselves.

Events ​

EventPayloadDescription
update:modelValuevalue: string | null, color: HabPaletteColor | null—

Slots ​

SlotBindingsDescription
trigger{ color: HabPaletteColor; hex: string }Replaces the default swatch-plus-name trigger. color is the palette entry, undefined off the palette; hex is what to paint, and is also set for a hex that is on no swatch.

HabColorGrid ​

Props ​

PropTypeDefaultDescription
allowClearbooleanfalseAdds a Clear swatch, for a genuinely optional color.
clearLabelstringundefinedAccessible name for the Clear swatch. Required whenever allowClear is set: the swatch is a struck-through square, so this is its only name. No default — the string is the host application's to translate.
format"token" | "hex""token"What the model holds: token if the color only ever renders inside a Habitus page, hex if it is stored and rendered anywhere else.
huesreadonly ("teal" | "lime" | "green" | "red" | "magenta" | "orange" | "blue" | "purple" | "gray")[]HAB_PALETTE_HUESRestrict to some hues, in this order.
includeExtrasbooleantrueThe base row: light and dark.
labelstringundefinedAccessible name for the grid.
modelValuestring | nullundefinedThe chosen color, as a token or a hex according to format. null is "no color". Bind with v-model.
showLabelsbooleantrueHue name at the start of each row, on by default as in the design source. Off leaves a bare grid of 65 unnamed colors — fine where space is tight and the hues speak for themselves.

Events ​

EventPayloadDescription
update:modelValuevalue: string | null, color: HabPaletteColor | null—

Proprietary — internal use only.