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
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
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.
npm install @eunomia/elements
<script type="module">
import "@eunomia/elements/color-picker.js";
</script>
<eun-color-picker label="Accent color">
<option value="#f14959">Red</option>
</eun-color-picker>
npm install @eunomia/elements
import "@eunomia/elements/color-picker.js";
function AccentField() {
return (
<eun-color-picker label="Accent color" name="accent">
<option value="#f14959">Red</option>
<option value="#3d6fff">Blue</option>
</eun-color-picker>
);
}
npm install @eunomia/elements
"use client";
import "@eunomia/elements/color-picker.js";
export function AccentField() {
return (
<eun-color-picker label="Accent color" name="accent">
<option value="#f14959">Red</option>
<option value="#3d6fff">Blue</option>
</eun-color-picker>
);
}
npm install @eunomia/elements
<script setup>
import "@eunomia/elements/color-picker.js";
</script>
<template>
<eun-color-picker label="Accent color" name="accent">
<option value="#f14959">Red</option>
<option value="#3d6fff">Blue</option>
</eun-color-picker>
</template>
npm install @eunomia/elements
import { CUSTOM_ELEMENTS_SCHEMA, Component } from "@angular/core";
import "@eunomia/elements/color-picker.js";
@Component({
selector: "app-accent-field",
template: `
<eun-color-picker label="Accent color" name="accent">
<option value="#f14959">Red</option>
<option value="#3d6fff">Blue</option>
</eun-color-picker>
`,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class AccentFieldComponent {}
Guidance
- Always set
label(or slot one): see the Accessibility tab - Declare a handful of curated
optionpresets 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-selectinstead - Overriding colors with inline styles instead of the
--field-*/--color-picker-*CSS variables
Live testing
Properties
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
| aria-label | string | — | 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-value | string | — | The default value applied when the field connects, and restored on form reset |
| readonly-available | boolean | false | Whether the field can switch between a static display and an editable one |
| empty-value-label | string | 'No color selected' | Text displayed in place of the value when empty |
| swatches-label | string | 'Presets' | Heading shown above the preset swatches, while any are declared |
| custom-label | string | 'Custom' | Heading shown above the custom color row |
| native-input-label | string | 'Pick a custom color' | Accessible label for the native OS color picker |
| hex-input-label | string | 'Hex color' | Accessible label for the hex text input |
| confirm-label | string | 'Confirm' | The label of the confirm button in edit mode |
| cancel-label | string | 'Cancel' | The label of the cancel button in edit mode |
| no-animation | boolean | false | Disables the dropdown panel's open and close height transition, on top of the automatic reduced motion handling |
| swatch-only | boolean | false | Renders 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-only | boolean | false | Restricts 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-inline | boolean | false | Renders 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
Properties
JS-only — no matching HTML attribute, set these from a script or a template binding.
| Name | Type | Default | Description |
|---|---|---|---|
| value | string | — | The current color, as a `#rrggbb` hex string |
| variant | 'outline' | 'fill' | 'underline' | 'outline' | The visual variant to apply to the field |
| label | string | — | The title label of the field, rendered through the label component |
| instructions | string | — | Instructions displayed below the label |
| name | string | — | The field name |
| disabled | boolean | false | Whether the field is disabled |
| readonly | boolean | false | Whether the field is read only |
| required | boolean | false | Whether a color is required |
| hideError | boolean | false | Whether the errors are hidden |
| validators | Array<Validators<string>> | — | The list of validation rules applied to the field value |
| readonlyAvailable | boolean | false | Whether the field can switch between a static display and an editable one |
Slots
| Name | Description |
|---|---|
| (default) | The `option` elements defining the preset swatches. Ignored entirely while swatchOnly is set |
Events
| Name | Type | Description |
|---|---|---|
| eunchange | ChangeEvent | Fired whenever a color is picked, whether a preset, the native OS picker, or a confirmed hex value |
| euncommit | CommitEvent | Fired when an edit is confirmed in readonlyAvailable mode |
Every event above follows the same naming convention, covered in
CSS custom properties
| Name | Description |
|---|---|
| --field-background | Sets the background color of the field |
| --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-padding | Sets the padding 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 |
| --field-listbox-max-height | Sets the maximum height of the dropdown panel, past which it scrolls |
| --field-listbox-transition-duration | Sets the duration of the dropdown panel's open and close animation |
| --color-picker-swatch-size | Sets the diameter of every swatch |
| --eun-viewport-inset-top | Reserves 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-bottom | The 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
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
--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 openaria-labelon the panel itself, taken from the field's ownlabelrole="radiogroup"on the preset swatches, each an individualrole="radio"/aria-checked="true"\|"false", and each its own real Tab stop (tabindex="0"), so every preset is reachable byTabalone, exactly like Arrow/Home/End already move between them. Arrow/Home/End only move focus, they never changearia-checkedon their own, since browsing the palette is meant to be non-destructivearia-labelon each preset swatch, taken from itsoption's text contentaria-invalid="true"\|"false"on the hex input: reflects whether its current typed text normalizes to a real color, independently from the field's ownvalidaria-required="true"\|"false"/aria-invalid="true"\|"false"on the trigger button: kept in sync withrequired/validaria-label: set from thelabelproperty directly on the trigger button and on thereadonly-availablestatic display, for the same shadow-root-boundary reason detailed ineun-input's Accessibility tabaria-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