Eunomia v1.1.0
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-labelstring—Fallback 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-valuestring—The 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—Text displayed in place of the value when empty. Defaults to the localized string for the current locale (English: "No color selected")
swatches-labelstring—Heading shown above the preset swatches, while any are declared. Defaults to the localized string for the current locale (English: "Presets")
custom-labelstring—Heading shown above the custom color row. Defaults to the localized string for the current locale (English: "Custom")
native-input-labelstring—Accessible label for the native OS color picker. Defaults to the localized string for the current locale (English: "Pick a custom color")
hex-input-labelstring—Accessible label for the hex text input. Defaults to the localized string for the current locale (English: "Hex color")
confirm-labelstring—The label of the confirm button in edit mode. Defaults to the localized string for the current locale (English: "Confirm")
cancel-labelstring—The label of the cancel button in edit mode. Defaults to the localized string for the current locale (English: "Cancel")
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 or 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
checkLocalValidators—Reads local validation state : an invalid hex typed in the inline field, or an assigned `value` that isn't a color, reports `badInput`.
valuestring—The current color, as a `#rrggbb` hex string
appearance'outline' | 'flat' | 'underline''outline'How the field's surface is drawn: a bordered box, a muted background with no border, or a bottom border only
roundedbooleanfalseWhether the field is fully rounded, as a pill. A custom border radius still wins over it
size'xs' | 's' | 'm' | 'l' | 'xl''m'The field size. A field and a button of the same size share the same height
labelstring—The title label of the field, rendered through the label component
instructionsstring—Instructions displayed below the label
hideLabelbooleanfalseWhether the label is visually hidden. It still gives the field its accessible name, and the instructions stay visible
namestring—The field name
disabledbooleanfalseWhether the field is disabled
readonlybooleanfalseWhether the field is read only
requiredbooleanfalseWhether a color is required
hideErrorbooleanfalseWhether the errors are hidden. The hint stays visible
invalidbooleanfalseShows the field as invalid without any message, for an error displayed elsewhere
errorstring—An error computed outside the field, by the form or by the server. While set, the field is invalid and shows this message right away
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
euneditcancelEditCancelEventFired when an edit is cancelled in readonlyAvailable mode, after the previous value is restored

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

CSS custom properties

NameDescription
--field-backgroundSets the background color of the field, the surface color by default for the outline appearance
--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-padding-blockSets the vertical padding, as one length applied to both sides. Ignored when --field-padding is set
--field-padding-inlineSets the horizontal padding, as one length applied to both sides. Ignored when --field-padding is set
--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-border-radiusSets the corner radius of the dropdown panel, independent of the field's own radius
--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
--field-font-sizeSets the font size of the field's value and placeholder
--field-line-heightSets the line height of the field's value and placeholder
--field-label-font-sizeSets the font size of the label
--field-message-font-sizeSets the font size of the hint, error messages and instructions

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"

Display modes

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 listbox. 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.

Appearance

appearance selects the visual style: outline (default, bordered box), flat (muted background, no border), or underline (bottom border only).

<eun-color-picker
  label="Flat"
  appearance="flat"
  value="#3d6fff"
></eun-color-picker>

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

States and editing

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 and dispatches euneditcancel.

<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>

The custom properties that change colors, borders, shadows and corners, at a glance. The API tab lists every property of the component.

Variable Sets
--field-background Sets the background color of the field, the surface color by default for the outline appearance
--field-border-color Sets the border color of the field
--field-border-color-hover Sets the border color on hover
--field-border-color-focus Sets the border color on focus
--field-border-radius Sets the corner radius of the field
--field-text-color Sets the text color of the field
--field-placeholder-color Sets the color of the empty-value text
--field-icon-color Sets the color of the dropdown arrow
--field-error-color Sets the color of the error message
--field-hint-color Sets the color of the hint message
--field-focus-outline-color Sets the color of the focus outline
--field-edit-confirm-color Sets the color of the confirm button in readonly-available edit mode
--field-edit-cancel-color Sets the color of the cancel button in readonly-available edit mode
--color-picker-swatch-size Sets the diameter of every swatch
--field-listbox-border-radius Sets the corner radius of the dropdown panel, independent of the field's own radius

Keyboard and focus

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 between the presets, a single stop, and 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

The presets share a single Tab stop, on the selected preset (or the first one), which then follows the one last reached with the arrow keys. Moving between them never picks a color on its own, so reviewing the whole palette 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.

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="dialog" / aria-expanded="true"\|"false" on the trigger button: reflects whether the panel is open
  • role="dialog" and aria-label on the panel itself, taken from the field's own label
  • role="listbox" on the preset swatches, each an individual role="option" / aria-selected="true"\|"false", sharing a single roving Tab stop. Arrow/Home/End only move the focus, never aria-selected, since browsing the palette is meant to be non-destructive: the listbox pattern's selection that doesn't follow focus
  • 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. An invalid hex is kept in the input rather than reverted, and in the inline field (no presets) it also makes the field invalid (badInput), as does a value that isn't a color
  • aria-required="true"\|"false" / aria-invalid="true"\|"false" on the trigger button: kept in sync with required/valid, and forced to true while invalid is set
  • 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

Field states

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 stays in edit mode, shows its error and gets the focus back, rather than silently discarding the edit or committing bad data. Canceling always succeeds and restores the value present when edit mode was entered, then dispatches euneditcancel. Once the edit is confirmed or canceled, the focus moves to the static display, so it isn't lost along with the edit buttons.

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.

Server-side rendering

The label, the instructions and the field are drawn inside the color picker's shadow root, so a server-rendered color picker takes no space until the element upgrades, then pushes the content below it. Link field-ssr.css in the page's <head> to draw them from the first paint:

<link
  rel="stylesheet"
  href="node_modules/@eunomia/elements/dist/ssr/field-ssr.css"
/>

A bundler that resolves package exports can reach it as @eunomia/elements/field-ssr.css. It reads label, required, instructions, size, rounded, appearance, disabled, readonly, swatch-only, readonly-available, presets-inline and the --field-* custom properties, written in the server-rendered markup. The label and the instructions take their final height, and the field its final box. The preset options stay hidden, and the swatches, the value and the messages show with the upgrade, in the room already kept for them.

Reference links

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