Eunomia v1.0.0-beta.2
AllAngularReactNext.jsJavaScriptVue
EunomiaSalmonForestVioletOceanGoldFireCustom…
🇬🇧 English🇫🇷 Français

Color picker

Color picker lets someone pick a color, either from a set of preset swatches you declare or a fully custom value, entered through the browser's own OS color picker or typed as a hex code. Its value is always a plain #rrggbb hex string, so it drops straight into anything already expecting one, a brand color, an event's accent, a tag color.

Dependencies

eun-label · if it has a label eun-button · in readonly-available edit mode
Overview API Examples Accessibility

When to use

Reach for color picker whenever someone needs to assign a color to something, a brand color, a category, an event, rather than picking from a list of unrelated options (use select for that instead). Declare option presets when there's a curated palette worth surfacing first, exactly like eun-select's own option children ; leave them out entirely for a purely free-form pick, which is all a brand-color field typically needs.

Install & usage

Pick a framework in the toolbar above and these snippets adapt.

npm install @eunomia/elements
import "@eunomia/elements/color-picker.js";
import "@eunomia/elements/label.js";
<eun-color-picker label="Accent color">
  <option value="#f14959">Red</option>
  <option value="#3d6fff">Blue</option>
</eun-color-picker>

Importing the file registers <eun-color-picker> as a custom element, with no further setup needed. It works with any framework, or none, since it's a standard web component.

Guidance

  • Always set label (or slot one): see the Accessibility tab
  • Declare a handful of curated option presets whenever a brand or category palette already exists, so most people never need to open the native OS picker at all
  • Leave presets out entirely for a field whose whole point is a free pick, such as a brand color
  • Let the hex input and the native color input coexist : typing is faster for someone who already knows the value, the OS picker is friendlier for someone who doesn't
  • Using color picker for a set of unrelated named options : reach for eun-select instead
  • Overriding colors with inline styles instead of the --field-*/--color-picker-* CSS variables

Live testing

Properties

Attributes

NameTypeDefaultDescription
aria-labelstringFallback accessible name for the trigger, used when `label` isn't set. Recommended whenever the field renders with no visible label, such as `swatchOnly` used bare
default-valuestringThe default value applied when the field connects, and restored on form reset
readonly-availablebooleanfalseWhether the field can switch between a static display and an editable one
empty-value-labelstring'No color selected'Text displayed in place of the value when empty
swatches-labelstring'Presets'Heading shown above the preset swatches, while any are declared
custom-labelstring'Custom'Heading shown above the custom color row
native-input-labelstring'Pick a custom color'Accessible label for the native OS color picker
hex-input-labelstring'Hex color'Accessible label for the hex text input
confirm-labelstring'Confirm'The label of the confirm button in edit mode
cancel-labelstring'Cancel'The label of the cancel button in edit mode
no-animationbooleanfalseDisables the dropdown panel's open and close height transition, on top of the automatic reduced motion handling
swatch-onlybooleanfalseRenders as a native input type="color" styled as a plain swatch, with no panel, no presets, and no hex input at all — only the browser/OS's own color picker. Presets declared as option children are ignored while this is set
presets-onlybooleanfalseRestricts the field to only the declared option presets : no leading native color swatch, and the panel drops its hex input entirely, leaving only the presets
presets-inlinebooleanfalseRenders the option presets directly as the field itself, with no trigger button and no panel to open at all

Import the exact TypeScript type behind any property above, see Types.

Properties

JS-only — no matching HTML attribute, set these from a script or a template binding.

NameTypeDefaultDescription
valuestringThe current color, as a `#rrggbb` hex string
variant'outline' | 'fill' | 'underline''outline'The visual variant to apply to the field
labelstringThe title label of the field, rendered through the label component
instructionsstringInstructions displayed below the label
namestringThe field name
disabledbooleanfalseWhether the field is disabled
readonlybooleanfalseWhether the field is read only
requiredbooleanfalseWhether a color is required
hideErrorbooleanfalseWhether the errors are hidden
validatorsArray<Validators<string>>The list of validation rules applied to the field value
readonlyAvailablebooleanfalseWhether the field can switch between a static display and an editable one

Slots

NameDescription
(default)The `option` elements defining the preset swatches. Ignored entirely while swatchOnly is set

Events

NameTypeDescription
eunchangeChangeEventFired whenever a color is picked, whether a preset, the native OS picker, or a confirmed hex value
euncommitCommitEventFired when an edit is confirmed in readonlyAvailable mode

Every event above follows the same naming convention, covered in Events.

CSS custom properties

NameDescription
--field-backgroundSets the background color of the field
--field-border-colorSets the border color of the field
--field-border-color-hoverSets the border color on hover
--field-border-color-focusSets the border color on focus
--field-border-radiusSets the corner radius of the field
--field-paddingSets the padding of the field
--field-text-colorSets the text color of the field
--field-placeholder-colorSets the color of the empty-value text
--field-icon-colorSets the color of the dropdown arrow
--field-error-colorSets the color of the error message
--field-hint-colorSets the color of the hint message
--field-focus-outline-colorSets the color of the focus outline
--field-edit-confirm-colorSets the color of the confirm button in readonly-available edit mode
--field-edit-cancel-colorSets the color of the cancel button in readonly-available edit mode
--field-listbox-max-heightSets the maximum height of the dropdown panel, past which it scrolls
--field-listbox-transition-durationSets the duration of the dropdown panel's open and close animation
--color-picker-swatch-sizeSets the diameter of every swatch
--eun-viewport-inset-topReserves space, such as a page's sticky header height, that the dropdown panel keeps clear of at the top of the viewport
--eun-viewport-inset-bottomThe same as eun-viewport-inset-top, reserved at the bottom of the viewport

Basic

<eun-color-picker label="Accent color">
  <option value="#f14959">Red</option>
  <option value="#3d6fff">Blue</option>
  <option value="#1bc47d">Green</option>
</eun-color-picker>

Read the picked color back as a plain hex string:

const picker = document.querySelector("eun-color-picker");
picker.addEventListener("eunchange", () => console.log(picker.value)); // e.g. "#3d6fff"

Presets

Declare as many option presets as the palette calls for. The panel opens with the leading native swatch and the curated presets side by side with a plain hex input, so picking one of the six below is one click, and anything outside the palette is still one field away, never a whole extra step to reach.

<eun-color-picker label="Status color" value="#3bb3b9">
  <option value="#f14959">Red</option>
  <option value="#9562e3">Purple</option>
  <option value="#f0f050">Yellow</option>
  <option value="#3bb3b9">Blue</option>
  <option value="#49f15f">Green</option>
  <option value="#d05df3">Pink</option>
</eun-color-picker>

Setting value to one of the preset values, as above, opens the panel with that swatch already picked ; setting it to anything else still works exactly the same, just with no preset shown as picked.

Without presets

Leave out option children entirely for a purely custom pick : with no presets to show in a panel, there's nothing a panel would add over showing the native OS picker and the hex input directly as the field itself, so that's what renders, no trigger button to click first, no panel at all. This is exactly what a plain brand-color field typically needs, and it's how the docs site's own toolbar uses this component for its "Custom…" primary/secondary color pickers.

<eun-color-picker label="Brand color" value="#3d6fff"></eun-color-picker>

Adding an option later, even dynamically from a script, switches the field over to the trigger-and-panel behavior automatically, since presence of presets is the only thing that decides which one renders.

Swatch only

Set swatch-only for the closest thing to a bare native <input type="color"> this component offers : the field renders as a real native color input styled as a plain swatch, no trigger text, no dropdown arrow, and, unlike every other mode, no panel of any kind, no presets, no hex input either. Clicking or activating the swatch opens the browser/OS's own color picker directly, nothing of this component's own in between. Any option presets declared are ignored entirely while this is set, since there's no panel left to show them in. The label above the field, if any, stays put, this only changes the value's own display and the panel behind it.

<eun-color-picker
  swatch-only
  label="Accent color"
  value="#3d6fff"
></eun-color-picker>

The trigger still needs an accessible name : set label as usual, or, with no visible label at all, it falls back to announcing the current value (or emptyValueLabel) itself.

The native OS color picker is never nested inside the panel in any mode, including the trigger-and-panel one where presets are declared, since it's always the leading swatch, reached without opening anything. That's what keeps the panel itself free to only ever hold presets and a plain hex input, neither of which conflicts with the native picker's own drag interaction the way nesting it inside a popover once did. swatch-only goes one step further and drops the button, the panel, and the hex input entirely, for a spot where only the bare native picker is needed at all.

Presets only

Set presets-only for the opposite trade-off from swatch-only : nothing outside the curated palette is reachable at all, not even by typing a hex value. The leading native color swatch is gone (the button shows a plain decorative swatch of its own instead), and the panel drops its hex input, leaving only the preset radiogroup. Requires at least one option to have any effect ; with none, there's no palette left to restrict to in the first place.

<eun-color-picker presets-only label="Status color" value="#f14959">
  <option value="#f14959">Red</option>
  <option value="#f0b429">Yellow</option>
  <option value="#1bc47d">Green</option>
</eun-color-picker>

Reach for this whenever a color must stay within a fixed, approved set, a status, a category, a brand palette, with no escape hatch to an arbitrary value.

Presets inline

Set presets-inline for the same restricted-to-the-palette idea as presets-only, but with the swatches shown directly as the field itself, no trigger button, no panel to open at all. Takes priority over presets-only and, like it, requires at least one option to have any effect.

<eun-color-picker presets-inline label="Status color" value="#f14959">
  <option value="#f14959">Red</option>
  <option value="#f0b429">Yellow</option>
  <option value="#1bc47d">Green</option>
</eun-color-picker>

A good fit for a compact space, a table cell, a toolbar, where opening a panel just to pick from a handful of colors would be one click too many.

Variants

variant selects the visual style: outline (default, bordered box), fill (filled background, no border), or underline (bottom border only).

<eun-color-picker
  label="Fill"
  variant="fill"
  value="#3d6fff"
></eun-color-picker>

Part of the shared vocabulary covered in Variants, alongside every other component that reuses it.

States

<eun-color-picker label="Disabled" disabled value="#3d6fff"></eun-color-picker>
<eun-color-picker label="Readonly" readonly value="#3d6fff"></eun-color-picker>
<eun-color-picker
  label="Required"
  required
  hint="This field is mandatory"
></eun-color-picker>

readonly and disabled are not interchangeable: a readonly field still submits its value and stays focusable, a disabled one does neither. See the Accessibility tab.

Click to edit

Set readonly-available to let a field switch between a static, clickable swatch-and-hex display and a real editable picker, instead of always being one or the other. Clicking the display (or activating it with Enter/ Space) switches into edit mode, showing Confirm and Cancel buttons (confirmLabel/cancelLabel) alongside the field. Picking a preset, an OS color, or a hex value only updates the draft, it does not commit on its own. Confirming dispatches euncommit with the new value and switches back to the static display, while canceling reverts to the previous value instead, with no event fired.

<eun-color-picker readonly-available label="Accent color" value="#3d6fff">
  <option value="#f14959">Red</option>
  <option value="#3d6fff">Blue</option>
</eun-color-picker>
document
  .querySelector("eun-color-picker[readonly-available]")
  .addEventListener("euncommit", (event) => save(event.target.value));

Theming

Colors come from the active theme's --eun-* tokens (see Theming). For one-off overrides, target the component's own --field-* custom properties (shared with eun-input/ eun-select) and --color-picker-swatch-size (full list in the API tab).

<eun-color-picker
  label="Custom"
  value="#3d6fff"
  style="
    --field-border-color: #b45309;
    --field-border-color-hover: #92400e;
    --field-border-color-focus: #92400e;
    --color-picker-swatch-size: 24px;
  "
></eun-color-picker>

Open and close animation

The panel's height transitions smoothly open and closed by default, exactly like eun-select's own dropdown. --field-listbox-transition-duration (default 200ms) controls how long that takes, and no-animation disables it entirely, same as it already does automatically whenever the browser reports prefers-reduced-motion: reduce:

<eun-color-picker no-animation label="Accent color">...</eun-color-picker>

Keyboard interactions

This section covers the trigger-and-panel mode, active once at least one preset is declared. With none, the field is the "Custom" row's own native color input and hex text input directly, each a real, standard form control (see the next table) with no trigger or panel involved at all. While swatch-only is set, none of this applies either : the field is a real native <input type="color">, with whatever keyboard interaction, focus behavior, and accessibility tree the browser/OS already builds for that input type, nothing layered on top of it.

The trigger is two separate tab stops : the leading native color swatch, always the very first thing reached, then the button that opens the panel. This mirrors exactly how the swatch is laid out visually, before the value, and it's never anything but this leading swatch, since the native picker is never reachable from inside the panel itself.

Key Action
Tab / Shift+Tab Moves focus between the native color swatch and the trigger button
Enter / Space, on the native swatch Opens the browser/OS's own color picker, its own native behavior, not this component's
Enter / Space, on the trigger button Opens the panel
ArrowDown, on the trigger button Opens the panel if closed

Once the panel is open :

Key Action
Tab / Shift+Tab Moves to the next/previous preset, then into the hex input
ArrowRight / ArrowDown Moves focus to the next preset, wrapping past the last one
ArrowLeft / ArrowUp Moves focus to the previous preset, wrapping past the first
Home / End Moves focus to the first/last preset
Enter / Space Picks the currently focused preset
Escape Closes the panel, via the Popover API's own light-dismiss

Every preset is its own real Tab stop, exactly as reachable one at a time by Tab/Shift+Tab as by ArrowRight/ArrowLeft/Home/End, and neither way ever picks a color on its own, so reviewing the whole palette, by either method, never changes the field's value by accident. The hex text input, when present, is the last stop after the presets, a real, standard form control with whatever keyboard interaction the browser/OS already gives it on top of everything above.

On the read-only click-to-edit display (readonly-available, not editing):

Key Action
Enter Enters edit mode
Space Enters edit mode

Dismissal behavior

The panel closes on: Escape, a click outside it (both native Popover API light-dismiss), picking a preset or a hex value, and the browser window itself losing focus, for example Alt-Tabbing to another application, the same set eun-select's own dropdown closes on and for the same reasons (see its own Accessibility tab for the full rationale, including why the window-blur case needs its own explicit handling).

Focus leaving the field and the panel entirely, for example Tab-ing to whatever comes next in the page, also closes it, checked a frame after every focusout against document.activeElement rather than :focus-within (the preset swatches are real Tab stops, reachable from the trigger straight into the still-open panel, and :focus-within doesn't correctly cross into a popover's own top-layer content in current browsers), so moving focus between the leading native swatch, the trigger button, the panel, and the confirm/cancel buttons in readonly-available mode doesn't trigger a false close. Picking a color through the leading native OS picker also closes the panel if it happened to be open, though opening that picker never requires the panel to be open in the first place, since the native swatch works independently of it. Closing the panel this way only closes the panel, it never exits edit mode on its own in readonly-available mode: see Readonly-available / click-to-edit flow below.

Motion

The panel's open/close height transition is skipped automatically whenever the browser reports prefers-reduced-motion: reduce, on top of the explicit no-animation attribute/property covered in the Examples tab.

Focus indicator

The field renders a :focus-visible outline (--field-focus-outline-color, falling back to --eun-color-primary-500) that switches to --field-error-color while invalid, exactly like eun-input/eun-select. The leading native swatch, each preset swatch, the confirm and cancel buttons, and the hex input get the same outline treatment when focused. The native color input itself is fully transparent, so its outline is drawn on the visible swatch span behind it instead, using :has(:focus-visible) on the swatch's own wrapper, keyboard focus only, exactly like every other outline on the field. swatch-only's trigger uses the identical technique for the same reason.

Aria attributes

  • aria-haspopup="true" / aria-expanded="true"\|"false" on the trigger button: reflects whether the panel is open
  • aria-label on the panel itself, taken from the field's own label
  • role="radiogroup" on the preset swatches, each an individual role="radio" / aria-checked="true"\|"false", and each its own real Tab stop (tabindex="0"), so every preset is reachable by Tab alone, exactly like Arrow/Home/End already move between them. Arrow/Home/End only move focus, they never change aria-checked on their own, since browsing the palette is meant to be non-destructive
  • aria-label on each preset swatch, taken from its option's text content
  • aria-invalid="true"\|"false" on the hex input: reflects whether its current typed text normalizes to a real color, independently from the field's own valid
  • aria-required="true"\|"false" / aria-invalid="true"\|"false" on the trigger button: kept in sync with required/valid
  • aria-label: set from the label property directly on the trigger button and on the readonly-available static display, for the same shadow-root-boundary reason detailed in eun-input's Accessibility tab
  • aria-describedby="description": set on the trigger button whenever a hint or error message is actually rendered below it

Disabled vs. readonly

disabled removes the field from the tab order and excludes its value from form submission entirely. readonly keeps it focusable and its value submitted, and additionally prevents the panel from opening at all, communicating "you can't change this right now", not "this doesn't apply".

Readonly-available / click-to-edit flow

The static display exposes role="button" and full keyboard access (Tab to reach it, Enter/Space to activate it), so it's operable without a pointer despite looking like a swatch and text. Picking a preset, an OS color, or a hex value only updates the draft, exactly like typing does in eun-input: it never commits or exits edit mode on its own. Confirming re-validates first, an invalid value keeps focus in the field and the error message shown, rather than silently discarding the edit or committing bad data. Canceling always succeeds and restores the value present when edit mode was entered, with no event fired.

Reference links

  • WAI-ARIA Authoring Practices: Radio Group Pattern
  • WAI Web Accessibility Tutorials: Labeling Controls
  • WHATWG HTML: Popover API
  • CSS Anchor Positioning: Working Draft