GitHub

Checkbox Group

Multi-select from an options list; the value is a string[]. Vertical or horizontal orientation, per-option disabled, Space/Enter toggling. The options attribute accepts a JSON string. Emits a composed change event.

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

Preview

<esa-checkbox-group
  label="Monitored media"
  options='[{"label":"Air quality","value":"air"}, …]'
  value='["water"]'
></esa-checkbox-group>
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import '@esa/ecology/esa-checkbox-group';

@Component({
  selector: 'app-checkbox-group-demo',
  standalone: true,
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
  template: `
    <esa-checkbox-group
      label="Monitored media"
      options='[{"label":"Air quality","value":"air"}, …]'
      value='["water"]'
    ></esa-checkbox-group>
  `,
})
export class CheckboxGroupDemo {}

Orientation

vertical is the default; switch to horizontal to lay options out in a row.

<esa-checkbox-group label="Monitored media" orientation="horizontal" options="…"></esa-checkbox-group>
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import '@esa/ecology/esa-checkbox-group';

@Component({
  selector: 'app-checkbox-group-demo',
  standalone: true,
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
  template: `
    <esa-checkbox-group label="Monitored media" orientation="horizontal" options="…"></esa-checkbox-group>
  `,
})
export class CheckboxGroupDemo {}

Sizes

<esa-checkbox-group label="Extra small" size="xs" orientation="horizontal" options="…"></esa-checkbox-group>
<esa-checkbox-group label="Small" size="sm" orientation="horizontal" options="…"></esa-checkbox-group>
<esa-checkbox-group label="Medium" size="md" orientation="horizontal" options="…"></esa-checkbox-group>
<esa-checkbox-group label="Large" size="lg" orientation="horizontal" options="…"></esa-checkbox-group>
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import '@esa/ecology/esa-checkbox-group';

@Component({
  selector: 'app-checkbox-group-demo',
  standalone: true,
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
  template: `
    <esa-checkbox-group
      label="Extra small"
      size="xs"
      orientation="horizontal"
      options="…"
    ></esa-checkbox-group>
    <esa-checkbox-group label="Small" size="sm" orientation="horizontal" options="…"></esa-checkbox-group>
    <esa-checkbox-group label="Medium" size="md" orientation="horizontal" options="…"></esa-checkbox-group>
    <esa-checkbox-group label="Large" size="lg" orientation="horizontal" options="…"></esa-checkbox-group>
  `,
})
export class CheckboxGroupDemo {}

Form participation

Each selected value is submitted under name. Submitting logs all media entries.

<form>
  <esa-checkbox-group label="Media" name="media" options="…" value='["air"]'></esa-checkbox-group>
  <button type="submit">Submit</button>
</form>
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import '@esa/ecology/esa-checkbox-group';

@Component({
  selector: 'app-checkbox-group-demo',
  standalone: true,
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
  template: `
    <form>
      <esa-checkbox-group label="Media" name="media" options="…" value='["air"]'></esa-checkbox-group>
      <button type="submit">Submit</button>
    </form>
  `,
})
export class CheckboxGroupDemo {}

API

PropTypeDefaultDescription
options EsaOption[] [] Array of {'{ label, value, disabled? }'}, or a JSON string of the same when set as an attribute.
label string '' Group label rendered above the options.
size 'xs' | 'sm' | 'md' | 'lg' 'md' Box and label size.
orientation 'vertical' | 'horizontal' 'vertical' Stacking direction of the options.
name string | undefined — Field name each selected value is submitted under.
value string[] [] Selected option values.
required boolean false Whether at least one option must be chosen. Unlike esa-radio-group this canNOT use `aria-required`: a checkbox group is a `group`, and ARIA only allows aria-required on widget roles like `radiogroup`. Overriding the role to radiogroup would be a lie — radiogroup means single-select. So the requirement goes where the spec's own technique (H90) puts it: in the group's ACCESSIBLE NAME, as "(select at least one)" appended to the legend.
help-text string '' Property: `helpText`.
error-text string '' Validation message below the group; reddens the legend and renders the error line. Property: `errorText`.
live-error boolean false Announce the error the moment it appears rather than only when the group is entered. Off by default — see the long note on `esa-text-field.liveError`. Property: `liveError`.

Events

EventTypeDefaultDescription
change CustomEvent<{ value: string[] }> — Fired when any option is toggled. Composed and bubbling; detail.value is the current selection array.
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 options container has role="group", named by aria-label from the label attribute — so leaving label unset leaves the group unnamed. Note that role="group" has weak support on iOS VoiceOver and Android TalkBack; a native <fieldset>/<legend> is the more reliable grouping.
  • Each box has role="checkbox" with aria-checked and toggles on Space or Enter.
  • Known gaps. The option text sits in a sibling <span>, and a wrapping <label> cannot name a <span role="checkbox"> — options currently announce without their text. The click target is the box alone, not the row, so the visible text is not clickable and the target is 14–20px at xs–md, under the 24×24 minimum. esa-checkbox binds the whole row and does not have this.
  • Disabled options carry aria-disabled and a -1 tabindex.
  • 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-body-2xs
  • --typography-body-2xs-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-body-2xs-font-size clamp(0.5rem, 0.44rem + 0.3vw, 0.625rem) via --font-size-050
  • --typography-body-2xs-font-weight 350 via --typography-font-weight-regular → --font-weight-350
  • --typography-body-2xs-letter-spacing 0.01em via --letter-spacing-default
  • --typography-body-2xs-line-height 1.6 via --line-height-normal
+6 more components esa-checkbox, esa-combobox, esa-input-tag, esa-progress-bar, esa-radio-group, esa-textarea
.typography-body-lg
  • --typography-body-lg-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-body-lg-font-size clamp(0.875rem, 0.77rem + 0.52vw, 1.125rem) via --font-size-300
  • --typography-body-lg-font-weight 350 via --typography-font-weight-regular → --font-weight-350
  • --typography-body-lg-letter-spacing 0.01em via --letter-spacing-default
  • --typography-body-lg-line-height 1.8 via --line-height-relaxed
+7 more components esa-breadcrumbs, esa-checkbox, esa-combobox, esa-input-tag, esa-radio-group, esa-select, esa-textarea
.typography-body-md
  • --typography-body-md-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-body-md-font-size clamp(0.75rem, 0.66rem + 0.44vw, 0.9375rem) via --font-size-200
  • --typography-body-md-font-weight 350 via --typography-font-weight-regular → --font-weight-350
  • --typography-body-md-letter-spacing 0.01em via --letter-spacing-default
  • --typography-body-md-line-height 1.6 via --line-height-normal
+28 more components esa-app-shell, esa-breadcrumbs, esa-card, esa-checkbox, esa-collapsible, esa-combobox, esa-command-palette, esa-confirm-dialog, esa-container, esa-danger-zone, esa-dialog, esa-dropdown-menu, esa-empty-state, esa-entity-search, esa-form-field, esa-header-nav, esa-input-tag, esa-nav-dropdown, esa-page-header, esa-popover, esa-progress-bar, esa-radio-group, esa-select, esa-side-dialog, esa-snackbar-item, esa-switch-toggle, esa-tab-layout, esa-textarea
.typography-body-sm
  • --typography-body-sm-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-body-sm-font-size clamp(0.6875rem, 0.61rem + 0.38vw, 0.875rem) via --font-size-150
  • --typography-body-sm-font-weight 350 via --typography-font-weight-regular → --font-weight-350
  • --typography-body-sm-letter-spacing 0.01em via --letter-spacing-default
  • --typography-body-sm-line-height 1.6 via --line-height-normal
+20 more components esa-alert-box, esa-breadcrumbs, esa-button-toggle, esa-card, esa-combobox, esa-command-palette, esa-date-picker, esa-empty-state, esa-file-upload, esa-form-field, esa-input-tag, esa-link-column, esa-loading-overlay, esa-progress-bar, esa-radio-group, esa-search-panel, esa-select, esa-stat, esa-text-field, esa-textarea
.typography-body-xs
  • --typography-body-xs-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-body-xs-font-size clamp(0.625rem, 0.56rem + 0.32vw, 0.75rem) via --font-size-100
  • --typography-body-xs-font-weight 350 via --typography-font-weight-regular → --font-weight-350
  • --typography-body-xs-letter-spacing 0.01em via --letter-spacing-default
  • --typography-body-xs-line-height 1.6 via --line-height-normal
+14 more components esa-breadcrumbs, esa-checkbox, esa-combobox, esa-command-palette, esa-empty-state, esa-entity-search, esa-field-error, esa-header-nav, esa-input-tag, esa-progress-bar, esa-radio-group, esa-search-panel, esa-select, esa-textarea
.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-color-picker, 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-color-picker, 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-color-picker, 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-color-picker, 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

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-error-border-color component forms group · +9 more #ce2c31 via --color-background-utility-danger → --color-red-11
--form-error-color component forms group · +9 more #ce2c31 via --color-content-utility-danger → --color-red-11
--form-help-color component forms group · +9 more #646464 via --color-content-default-secondary → --color-gray-11
--form-help-gap component forms group · +5 more 0.25rem via --spacing-100
--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-default-knockout semantic every component #fcfcfc via --color-gray-1
--radius-md semantic every component 0.5rem via --radius-200
--radius-sm 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-200 primitive every component 0.5rem
--spacing-400 primitive every component 1rem