GitHub

Chip Group

Single-select pill toggles — a horizontal row where exactly one chip is active. Each option carries a tone (neutral, neutral-strong, brand, or amber) that colors its active state. Arrow keys move, Enter or Space select. Form-associated; emits a composed change event.

Stable wc import '@esa/ecology/esa-chip-group'; Angular usage

Preview

Set the options property to a list of { value, label, tone? }. Exactly one chip is active, tracked by value.

<esa-chip-group label="Status filter" value="open"></esa-chip-group>

<script>
  document.querySelector('esa-chip-group').options = [
    { value: 'all', label: 'All', tone: 'neutral' },
    { value: 'open', label: 'Open', tone: 'brand' },
    { value: 'flagged', label: 'Flagged', tone: 'amber' },
  ];
</script>
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import '@esa/ecology/esa-chip-group';

@Component({
  selector: 'app-chip-group-demo',
  standalone: true,
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
  template: `
    <esa-chip-group label="Status filter" value="open" [options]="options"></esa-chip-group>
  `,
})
export class ChipGroupDemo {
  options = [
    { value: 'all', label: 'All', tone: 'neutral' },
    { value: 'open', label: 'Open', tone: 'brand' },
    { value: 'flagged', label: 'Flagged', tone: 'amber' },
  ];
}

Tones

Each option's tone sets the palette of its active state, mapped to Ecology semantic tokens. neutral is the default.

{ value: 'n',  label: 'Neutral',        tone: 'neutral' }
{ value: 'ns', label: 'Neutral strong', tone: 'neutral-strong' }
{ value: 't',  label: 'Brand',           tone: 'brand' }
{ value: 'a',  label: 'Amber',          tone: 'amber' }

Sizes

Chips follow the shared form size scale.

<esa-chip-group size="xs" ...></esa-chip-group>
<esa-chip-group size="sm" ...></esa-chip-group>
<esa-chip-group size="md" ...></esa-chip-group>
<esa-chip-group size="lg" ...></esa-chip-group>
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import '@esa/ecology/esa-chip-group';

@Component({
  selector: 'app-chip-group-demo',
  standalone: true,
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
  template: `
    <esa-chip-group size="xs" ...></esa-chip-group>
    <esa-chip-group size="sm" ...></esa-chip-group>
    <esa-chip-group size="md" ...></esa-chip-group>
    <esa-chip-group size="lg" ...></esa-chip-group>
  `,
})
export class ChipGroupDemo {}

API

PropTypeDefaultDescription
options EsaChipOption[] [] Array of {'{ value, label, tone? }'}, or a JSON string of the same when set as an attribute. tone is one of neutral, neutral-strong, brand, amber.
value string '' The selected option value. Exactly one chip is active at a time.
values string[] [] Selected values when `multiple`.
multiple boolean false
size 'xs' | 'sm' | 'md' | 'lg' 'md' Chip size on the shared form scale.
name string | undefined — Field name the selected value is submitted under.
label string '' Accessible label applied to the radiogroup (aria-label).

Events

EventTypeDefaultDescription
change CustomEvent<{ value: string }> — Fired when the selection changes via click or keyboard. Composed and bubbling; detail.value is the newly selected value.
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 host carries role="radiogroup" labeled by label (aria-label).
  • Each chip is a role="radio" with aria-checked; only the active chip is in the tab order (roving tabindex).
  • Arrow keys move selection with wrap-around; Home and End jump to the ends; selection follows arrow focus per the WAI-ARIA radio pattern.
  • Enter and Space select the focused chip.
  • The change event is composed and bubbles, so it can be caught on the host or an ancestor.

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-microcopy-2xs-strong
  • --typography-microcopy-2xs-strong-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-microcopy-2xs-strong-font-size clamp(0.5rem, 0.44rem + 0.3vw, 0.625rem) via --font-size-050
  • --typography-microcopy-2xs-strong-font-weight 550 via --typography-font-weight-semibold → --font-weight-550
  • --typography-microcopy-2xs-strong-letter-spacing 0.01em via --letter-spacing-default
  • --typography-microcopy-2xs-strong-line-height 1 via --line-height-none
+2 more components esa-badge, esa-button-toggle
.typography-microcopy-lg-strong
  • --typography-microcopy-lg-strong-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-microcopy-lg-strong-font-size clamp(0.875rem, 0.77rem + 0.52vw, 1.125rem) via --font-size-300
  • --typography-microcopy-lg-strong-font-weight 550 via --typography-font-weight-semibold → --font-weight-550
  • --typography-microcopy-lg-strong-letter-spacing 0.01em via --letter-spacing-default
  • --typography-microcopy-lg-strong-line-height 1 via --line-height-none
+1 more component esa-button-toggle
.typography-microcopy-md-strong
  • --typography-microcopy-md-strong-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-microcopy-md-strong-font-size clamp(0.75rem, 0.66rem + 0.44vw, 0.9375rem) via --font-size-200
  • --typography-microcopy-md-strong-font-weight 550 via --typography-font-weight-semibold → --font-weight-550
  • --typography-microcopy-md-strong-letter-spacing 0.01em via --letter-spacing-default
  • --typography-microcopy-md-strong-line-height 1 via --line-height-none
+2 more components esa-badge, esa-button-toggle
.typography-microcopy-xs-strong
  • --typography-microcopy-xs-strong-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-microcopy-xs-strong-font-size clamp(0.625rem, 0.56rem + 0.32vw, 0.75rem) via --font-size-100
  • --typography-microcopy-xs-strong-font-weight 550 via --typography-font-weight-semibold → --font-weight-550
  • --typography-microcopy-xs-strong-letter-spacing 0.01em via --letter-spacing-default
  • --typography-microcopy-xs-strong-line-height 1 via --line-height-none
+5 more components esa-badge, esa-button-toggle, esa-filter-dropdown, esa-sidebar-nav, esa-tab-layout

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
--border-width-default semantic every component 1px via --border-width-100
--color-background-brand-subtle semantic every component #fbfefb via --color-grass-1
--color-background-elevation-raised semantic every component #fcfcfc via --color-gray-1
--color-background-elevation-sunken semantic every component #f0f0f0 via --color-gray-3
--color-background-utility-warning-subtle semantic every component #fefdfb via --color-yellow-1
--color-border-brand semantic every component #b2ddb5 via --color-grass-6
--color-border-default semantic every component 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
--color-border-default-strong semantic every component #bbbbbb via --color-gray-8
--color-border-utility-warning semantic every component #f3d673 via --color-yellow-6
--color-content-brand semantic every component #2a7e3b via --color-grass-11
--color-content-default semantic every component #202020 via --color-gray-12
--color-content-default-secondary semantic every component #646464 via --color-gray-11
--color-content-utility-warning semantic every component #ab6400 via --color-yellow-11
--radius-chip semantic every component 0.25rem via --radius-100
--transition-fast semantic every component 150ms via --duration-150
--spacing-100 primitive every component 0.25rem
--spacing-150 primitive every component 0.375rem
--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