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 | — | Text displayed in place of the value when empty. Defaults to the localized string for the current locale (English: "No color selected") |
| swatches-label | string | — | Heading shown above the preset swatches, while any are declared. Defaults to the localized string for the current locale (English: "Presets") |
| custom-label | string | — | Heading shown above the custom color row. Defaults to the localized string for the current locale (English: "Custom") |
| native-input-label | string | — | Accessible label for the native OS color picker. Defaults to the localized string for the current locale (English: "Pick a custom color") |
| hex-input-label | string | — | Accessible label for the hex text input. Defaults to the localized string for the current locale (English: "Hex color") |
| confirm-label | string | — | The label of the confirm button in edit mode. Defaults to the localized string for the current locale (English: "Confirm") |
| cancel-label | string | — | The label of the cancel button in edit mode. Defaults to the localized string for the current locale (English: "Cancel") |
| 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 or 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 |
|---|---|---|---|
| checkLocalValidators | — | Reads local validation state : an invalid hex typed in the inline field, or an assigned `value` that isn't a color, reports `badInput`. | |
| value | string | — | 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 |
| rounded | boolean | false | Whether 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 |
| label | string | — | The title label of the field, rendered through the label component |
| instructions | string | — | Instructions displayed below the label |
| hideLabel | boolean | false | Whether the label is visually hidden. It still gives the field its accessible name, and the instructions stay visible |
| 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. The hint stays visible |
| invalid | boolean | false | Shows the field as invalid without any message, for an error displayed elsewhere |
| error | string | — | 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 |
| 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 |
| euneditcancel | EditCancelEvent | Fired when an edit is cancelled in readonlyAvailable mode, after the previous value is restored |
Every event above follows the same naming convention, covered in
CSS custom properties
| Name | Description |
|---|---|
| --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-padding | Sets the padding of the field |
| --field-padding-block | Sets the vertical padding, as one length applied to both sides. Ignored when --field-padding is set |
| --field-padding-inline | Sets the horizontal padding, as one length applied to both sides. Ignored when --field-padding is set |
| --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-border-radius | Sets the corner radius of the dropdown panel, independent of the field's own radius |
| --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 |
| --field-font-size | Sets the font size of the field's value and placeholder |
| --field-line-height | Sets the line height of the field's value and placeholder |
| --field-label-font-size | Sets the font size of the label |
| --field-message-font-size | Sets 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
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
--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 openrole="dialog"andaria-labelon the panel itself, taken from the field's ownlabelrole="listbox"on the preset swatches, each an individualrole="option"/aria-selected="true"\|"false", sharing a single roving Tab stop. Arrow/Home/End only move the focus, neveraria-selected, since browsing the palette is meant to be non-destructive: the listbox pattern's selection that doesn't follow focusaria-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. 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 avaluethat isn't a coloraria-required="true"\|"false"/aria-invalid="true"\|"false"on the trigger button: kept in sync withrequired/valid, and forced totruewhileinvalidis setaria-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
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