GitHub

Color Picker

Form-associated color picker — native color input, hex text field, and an optional swatch grid. A Lit Web Component migrated from the Angular esa-color-picker.

Stable wc import '@esa/ecology/esa-color-picker'; Angular usage

Preview

<esa-color-picker label="Brand color" value="#43608a"></esa-color-picker>
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import '@esa/ecology/esa-color-picker';

@Component({
  selector: 'app-color-picker-demo',
  standalone: true,
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
  template: `
    <esa-color-picker label="Brand color" value="#43608a"></esa-color-picker>
  `,
})
export class ColorPickerDemo {}

Swatches

Pass preset colors as a JSON-encoded swatches attribute (or an array property). The selected swatch is highlighted.

<esa-color-picker label="Pick a brand color" value="#43608a" swatches='["#43608a","#5787b9","#f9a134","#ef4444","#3b82f6","#22c55e"]'></esa-color-picker>
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import '@esa/ecology/esa-color-picker';

@Component({
  selector: 'app-color-picker-demo',
  standalone: true,
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
  template: `
    <esa-color-picker
      label="Pick a brand color"
      value="#43608a"
      swatches='["#43608a","#5787b9","#f9a134","#ef4444","#3b82f6","#22c55e"]'
    ></esa-color-picker>
  `,
})
export class ColorPickerDemo {}

Name the swatches when they stand for something

An entry can be { value, label } instead of a bare hex. The label becomes the swatch tooltip and its accessible name, so it announces as Select color Danger #ef4444 rather than a hex nobody can picture. Reach for it whenever the presets are a palette the system already has words for — a brand ramp, the utility intentions, the Radix hues on the theme maker. A one-off set of arbitrary colours has no name to give, and a bare string is right there.

<esa-color-picker label="Pick an intention" value="#43608a" swatches='[{"value":"#43608a","label":"Brand"},{"value":"#f9a134","label":"Warning"},{"value":"#ef4444","label":"Danger"}]'></esa-color-picker>
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import '@esa/ecology/esa-color-picker';

@Component({
  selector: 'app-color-picker-demo',
  standalone: true,
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
  template: `
    <esa-color-picker
      label="Pick an intention"
      value="#43608a"
      swatches='[{"value":"#43608a","label":"Brand"},{"value":"#f9a134","label":"Warning"},{"value":"#ef4444","label":"Danger"}]'
    ></esa-color-picker>
  `,
})
export class ColorPickerDemo {}

Sizes

<esa-color-picker label="Extra small" size="xs" value="#43608a"></esa-color-picker>
<esa-color-picker label="Small" size="sm" value="#5787b9"></esa-color-picker>
<esa-color-picker label="Medium" size="md" value="#f9a134"></esa-color-picker>
<esa-color-picker label="Large" size="lg" value="#ef4444"></esa-color-picker>
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import '@esa/ecology/esa-color-picker';

@Component({
  selector: 'app-color-picker-demo',
  standalone: true,
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
  template: `
    <esa-color-picker label="Extra small" size="xs" value="#43608a"></esa-color-picker>
    <esa-color-picker label="Small" size="sm" value="#5787b9"></esa-color-picker>
    <esa-color-picker label="Medium" size="md" value="#f9a134"></esa-color-picker>
    <esa-color-picker label="Large" size="lg" value="#ef4444"></esa-color-picker>
  `,
})
export class ColorPickerDemo {}

States

Hide the hex input with show-input={false}; disable the whole control with disabled.

<esa-color-picker label="No hex input" value="#3b82f6" show-input="false" swatches="…"></esa-color-picker>
<esa-color-picker label="Disabled" value="#22c55e" disabled swatches="…"></esa-color-picker>
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import '@esa/ecology/esa-color-picker';

@Component({
  selector: 'app-color-picker-demo',
  standalone: true,
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
  template: `
    <esa-color-picker
      label="No hex input"
      value="#3b82f6"
      show-input="false"
      swatches="…"
    ></esa-color-picker>
    <esa-color-picker label="Disabled" value="#22c55e" disabled swatches="…"></esa-color-picker>
  `,
})
export class ColorPickerDemo {}

API

PropTypeDefaultDescription
label string '' Field label above the control.
size 'xs' | 'sm' | 'md' | 'lg' 'md' Control size (reflected attribute).
swatches ColorSwatch[] [] Preset colors, as a property or a JSON-encoded attribute. Each entry is a hex string, or an object carrying a name: { value, label }. A named swatch announces the name instead of the hex.
disabled boolean false Disables interaction (reflected attribute).
name string | undefined — Form field name — the key this control submits under.
show-input boolean true Show the hex text input next to the preview. Property: `showInput`. On by default. Write `="false"` to turn it off — unusually for a boolean attribute, this one reads the value.
value string '#000000' Current color as a hex string.

Events

EventTypeDefaultDescription
change CustomEvent<{ value: string }> — Fired when the color commits (native picker, valid hex input, or swatch click). Hex input validates #rgb / #rrggbb. Composed and bubbles.
Binding these events from Angular

Every one of these is a CustomEvent, so the payload is on $event.detail — $event.target.value reads empty, with no error. The names are lowercase and Angular passes them to addEventListener verbatim, so camel-casing one means it never fires.

  • (change)="onChange($event.detail)"

Accessibility

  • The native <input type="color"> provides the OS color dialog and keyboard handling.
  • Swatches are a role="listbox" group; each swatch is role="option" with aria-selected and an aria-label — "Select color {label} {hex}" when the entry carries a label, "Select color {hex}" when it is a bare string.
  • Hex input only commits valid #rgb / #rrggbb values, preventing invalid form state.
  • Form-associated via ElementInternals — value submits with the enclosing form.

Theming surface

Every public token this component reads, extracted from its source at build time, split by blast radius: the hooks that belong to this component, then the shared and system tokens it consumes alongside everything else. The Resolves to column walks each token's real lineage — component → semantic → primitive → raw value — so you can see exactly where re-pointing it lands. A spoke re-skins by overriding these in its theme-<slug>.css; component internals are never edited. No hook for what you need? File it with /request-lego.

Type roles it names

This component does not set a size, weight, face or tracking of its own — it names a composite, and the composite carries all five. A composite is a class rather than a token, so none of it appears in the tables below; re-theme text by re-pointing the token family, not by editing the component.

RoleRe-pointReach
.typography-label-2xs
  • --typography-label-2xs-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-label-2xs-font-size clamp(0.5rem, 0.44rem + 0.3vw, 0.625rem) via --font-size-050
  • --typography-label-2xs-font-weight 500 via --typography-font-weight-medium → --font-weight-500
  • --typography-label-2xs-letter-spacing 0.01em via --letter-spacing-default
  • --typography-label-2xs-line-height 1.6 via --line-height-normal
+11 more components esa-button-toggle, esa-checkbox-group, esa-combobox, esa-date-picker, esa-form-field, esa-input-tag, esa-progress-bar, esa-radio-group, esa-range-slider, esa-text-field, esa-textarea
.typography-label-lg
  • --typography-label-lg-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-label-lg-font-size clamp(0.875rem, 0.77rem + 0.52vw, 1.125rem) via --font-size-300
  • --typography-label-lg-font-weight 500 via --typography-font-weight-medium → --font-weight-500
  • --typography-label-lg-letter-spacing 0.01em via --letter-spacing-default
  • --typography-label-lg-line-height 1.6 via --line-height-normal
+12 more components esa-breadcrumbs, esa-button-toggle, esa-checkbox-group, esa-combobox, esa-date-picker, esa-form-field, esa-input-tag, esa-radio-group, esa-range-slider, esa-select, esa-text-field, esa-textarea
.typography-label-md
  • --typography-label-md-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-label-md-font-size clamp(0.75rem, 0.66rem + 0.44vw, 0.9375rem) via --font-size-200
  • --typography-label-md-font-weight 500 via --typography-font-weight-medium → --font-weight-500
  • --typography-label-md-letter-spacing 0.01em via --letter-spacing-default
  • --typography-label-md-line-height 1.6 via --line-height-normal
+33 more components esa-app-bar, esa-breadcrumbs, esa-button-group, esa-button-toggle, esa-card, esa-checkbox-group, esa-combobox, esa-command-palette, esa-confirm-dialog, esa-danger-zone, esa-date-picker, esa-dialog, esa-empty-state, esa-entity-search, esa-file-upload, esa-filter-container, esa-form-field, esa-header-nav, esa-input-tag, esa-link-column, esa-page-header, esa-pillbox, esa-popover, esa-progress-bar, esa-radio-group, esa-range-slider, esa-select, esa-side-dialog, esa-sidebar-nav, esa-stat, esa-text-field, esa-textarea, esa-tooltip
.typography-label-xs
  • --typography-label-xs-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-label-xs-font-size clamp(0.625rem, 0.56rem + 0.32vw, 0.75rem) via --font-size-100
  • --typography-label-xs-font-weight 500 via --typography-font-weight-medium → --font-weight-500
  • --typography-label-xs-letter-spacing 0.01em via --letter-spacing-default
  • --typography-label-xs-line-height 1.6 via --line-height-normal
+14 more components esa-breadcrumbs, esa-button-toggle, esa-checkbox-group, esa-combobox, esa-date-picker, esa-entity-search, esa-form-field, esa-input-tag, esa-progress-bar, esa-radio-group, esa-range-slider, esa-select, esa-text-field, esa-textarea
.typography-microcopy-code-lg
  • --typography-microcopy-code-lg-font-family 'DM Mono', monospace via --typography-font-family-mono → --font-family-dm-mono
  • --typography-microcopy-code-lg-font-size clamp(0.875rem, 0.77rem + 0.52vw, 1.125rem) via --font-size-300
  • --typography-microcopy-code-lg-font-weight 350 via --typography-font-weight-regular → --font-weight-350
  • --typography-microcopy-code-lg-letter-spacing 0.01em via --letter-spacing-default
  • --typography-microcopy-code-lg-line-height 1 via --line-height-none
only here
.typography-microcopy-code-md
  • --typography-microcopy-code-md-font-family 'DM Mono', monospace via --typography-font-family-mono → --font-family-dm-mono
  • --typography-microcopy-code-md-font-size clamp(0.75rem, 0.66rem + 0.44vw, 0.9375rem) via --font-size-200
  • --typography-microcopy-code-md-font-weight 350 via --typography-font-weight-regular → --font-weight-350
  • --typography-microcopy-code-md-letter-spacing 0.01em via --letter-spacing-default
  • --typography-microcopy-code-md-line-height 1 via --line-height-none
only here
.typography-microcopy-code-sm
  • --typography-microcopy-code-sm-font-family 'DM Mono', monospace via --typography-font-family-mono → --font-family-dm-mono
  • --typography-microcopy-code-sm-font-size clamp(0.6875rem, 0.61rem + 0.38vw, 0.875rem) via --font-size-150
  • --typography-microcopy-code-sm-font-weight 350 via --typography-font-weight-regular → --font-weight-350
  • --typography-microcopy-code-sm-letter-spacing 0.01em via --letter-spacing-default
  • --typography-microcopy-code-sm-line-height 1 via --line-height-none
only here

Wired to this component

None. This component has no hooks of its own — it themes entirely through the shared tokens below, so every override here also moves its siblings.

Shared tokens it reads

Owned by a family or by the semantic layer. Still overridable — but scope the override (esa-foo { --form-border-color: … }) unless you mean to move everything that reads it. Reach says what else that is.

Token Reach Resolves to
--focus-ring-color component focus ring group · +34 more #3e9b4f via --color-border-default-focus → --color-grass-10
--focus-ring-offset component focus ring group · +31 more 2px
--focus-ring-width component focus ring group · +35 more 2px via --border-width-focus → --border-width-200
--form-border-color component forms group · +11 more one role per property, so a theme can move the ring's weight and its colour independently. Tier 3's --focus-ring-width now reads this instead of holding a bare 2px, which is the same shape --focus-ring-color has always had (a thin tier-3 alias over a tier-2 role) — width was simply never given the tier-2 half. * * This role was rejected once, and the rejection is worth recording because its PREMISE was wrong rather than its reasoning. The argument against an `emphasis` width was that every 2px border in the kit is micro-geometry — spinner rings, the kbd keycap lip, a chevron. The focus ring was not in that enumeration and does not fit it: 33 components render it, more than read any other tier-3 token, and it is not micro-geometry but an accessibility affordance whose weight is a legitimate brand axis (a spoke that needs a heavier ring for contrast has to be able to reach it). It is also not a `border` in any component — every consumer paints it with `outline` or `box-shadow` — which is precisely why a survey of BORDERS missed it. The name still says `border` because tier 2 already calls the ring's colour --color-border-default-focus via --color-border-default
--form-border-color-focus component forms group · +11 more #3e9b4f via --color-border-default-focus → --color-grass-10
--form-border-width component forms group · +10 more 1px via --border-width-default → --border-width-100
--form-text-color component forms group · +6 more #202020 via --color-content-default → --color-gray-12
--color-background-brand semantic every component #46a758 via --color-grass-9
--color-background-disabled semantic every component #f0f0f0 via --color-gray-3
--color-background-field semantic every component rgba(0, 0, 0, 0)
--color-border-disabled semantic every component #d9d9d9 via --color-gray-6
--color-content-default semantic every component #202020 via --color-gray-12
--color-content-disabled semantic every component #8d8d8d via --color-gray-9
--radius-md semantic every component 0.5rem via --radius-200
--radius-sm semantic every component 0.25rem via --radius-100
--radius-xs semantic every component 0.125rem via --radius-050
--transition-fast semantic every component 150ms via --duration-150
--spacing-100 primitive every component 0.25rem
--spacing-200 primitive every component 0.5rem
--spacing-250 primitive every component 0.625rem
--spacing-300 primitive every component 0.75rem
--spacing-400 primitive every component 1rem