GitHub

Button Toggle

A segmented single-select — one-of-N choices rendered as a row of connected buttons. Form-associated, holds the selected value, and follows the WAI-ARIA radiogroup pattern with arrow-key navigation.

Stable wc import '@esa/ecology/esa-button-toggle'; Angular usage

Preview

<esa-button-toggle label="View" id="bt-preview"></esa-button-toggle>

<script>
  el.options = [
    { value: 'list', label: 'List' },
    { value: 'grid', label: 'Grid' },
    { value: 'map', label: 'Map' },
  ];
  el.value = 'grid';
</script>
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import '@esa/ecology/esa-button-toggle';

@Component({
  selector: 'app-button-toggle-demo',
  standalone: true,
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
  template: `
    <esa-button-toggle label="View" id="bt-preview" [options]="options" [value]="value"></esa-button-toggle>
  `,
})
export class ButtonToggleDemo {
  options = [
    { value: 'list', label: 'List' },
    { value: 'grid', label: 'Grid' },
    { value: 'map', label: 'Map' },
  ];
  value = 'grid';
}

Sizes

Four sizes share the form scale. Default is md.

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

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

Icons

Each option may carry an icon — inner Lucide SVG markup (the same paths convention as esa-icon). It renders before the label and inherits currentColor, so it flips to the inverse color on the selected segment.

el.options = [
  { value: 'grid', label: 'Grid', icon: "<rect width='18' height='18' x='3' y='3' rx='2'/>…" },
  { value: 'kanban', label: 'Kanban', icon: "<path d='M3 3h18v18H3z'/>…" },
  { value: 'timeline', label: 'Timeline', icon: "<path d='M8 2v4'/>…" },
];

Omit label for an icon-only segment — set ariaLabel so it still has an accessible name.

el.options = [
  { value: 'grid', icon: "<rect …/>", ariaLabel: 'Grid' },
  { value: 'kanban', icon: "<path …/>", ariaLabel: 'Kanban' },
  { value: 'timeline', icon: "<path …/>", ariaLabel: 'Timeline' },
];

States

error-text replaces help-text in the slot below the group and reddens the track — the same one-slot precedence esa-select and esa-text-field use, so the two messages never stack.

<esa-button-toggle label="Default"></esa-button-toggle>
<esa-button-toggle label="With help" help-text="Pick a layout"></esa-button-toggle>
<esa-button-toggle label="With error" error-text="Choose a layout to continue"></esa-button-toggle>
<esa-button-toggle label="Disabled" disabled></esa-button-toggle>
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import '@esa/ecology/esa-button-toggle';

@Component({
  selector: 'app-button-toggle-demo',
  standalone: true,
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
  template: `
    <esa-button-toggle label="Default"></esa-button-toggle>
    <esa-button-toggle label="With help" help-text="Pick a layout"></esa-button-toggle>
    <esa-button-toggle label="With error" error-text="Choose a layout to continue"></esa-button-toggle>
    <esa-button-toggle label="Disabled" disabled></esa-button-toggle>
  `,
})
export class ButtonToggleDemo {}

Form participation

The control submits its selected value under its name. Submitting logs the view entry.

With required, the group reports valueMissing through ElementInternals until something is selected, so the native form refuses to submit and checkValidity() returns false — the asterisk is not the whole of it.

<form>
  <esa-button-toggle name="view" label="View"></esa-button-toggle>
  <button type="submit">Submit</button>
</form>
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import '@esa/ecology/esa-button-toggle';

@Component({
  selector: 'app-button-toggle-demo',
  standalone: true,
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
  template: `
    <form>
      <esa-button-toggle name="view" label="View"></esa-button-toggle>
      <button type="submit">Submit</button>
    </form>
  `,
})
export class ButtonToggleDemo {}

API

PropTypeDefaultDescription
label string '' Optional visible label above the group; becomes the radiogroup name.
help-text string '' Helper text below the group. Property: `helpText`.
error-text string '' Validation message below the group. Replaces help-text and reddens the track. Property: `errorText`.
options EsaToggleOption[] [] The segments. Set as a property (an array, not an attribute). Optional icon is inner Lucide SVG markup (the same paths convention as esa-icon), rendered before the label and inheriting currentColor. Omit label for an icon-only segment, but then set ariaLabel for its accessible name.
value string '' Selected option's value. Single-select; bound to the form value.
size 'xs' | 'sm' | 'md' | 'lg' 'md' Control size (shared form scale).
name string | undefined — Form field name — the key this control submits under.
disabled boolean false Disables every segment.
required boolean false Blocks submission while nothing is selected (valueMissing), and adds an asterisk plus aria-required.

Events

EventTypeDefaultDescription
change CustomEvent<{ value: string }> — Fired when the selection changes. 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

  • Renders role="radiogroup" wrapping role="radio" buttons with aria-checked reflecting selection.
  • Roving tabindex: only the selected (or first) segment is tab-reachable; Arrow keys move selection through the group, wrapping at the ends.
  • Home and End jump to the first and last segments; Enter and Space select the focused segment.
  • The label becomes the group's accessible name via aria-labelledby.
  • Visible focus ring on the active segment via --focus-ring-* tokens; the focused segment is raised so its ring sits above neighbors.
  • Best for small fixed option sets — past about six options, prefer a select.

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-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-card, esa-checkbox-group, 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-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-checkbox-group, 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-checkbox-group, 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-card, esa-checkbox-group, 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-checkbox-group, 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
.typography-microcopy-2xs
  • --typography-microcopy-2xs-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-microcopy-2xs-font-size clamp(0.5rem, 0.44rem + 0.3vw, 0.625rem) via --font-size-050
  • --typography-microcopy-2xs-font-weight 500 via --typography-font-weight-medium → --font-weight-500
  • --typography-microcopy-2xs-letter-spacing 0.01em via --letter-spacing-default
  • --typography-microcopy-2xs-line-height 1 via --line-height-none
+3 more components esa-button, esa-pill, esa-range-slider
.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-chip-group
.typography-microcopy-lg
  • --typography-microcopy-lg-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-microcopy-lg-font-size clamp(0.875rem, 0.77rem + 0.52vw, 1.125rem) via --font-size-300
  • --typography-microcopy-lg-font-weight 500 via --typography-font-weight-medium → --font-weight-500
  • --typography-microcopy-lg-letter-spacing 0.01em via --letter-spacing-default
  • --typography-microcopy-lg-line-height 1 via --line-height-none
+2 more components esa-button, esa-range-slider
.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-chip-group
.typography-microcopy-md
  • --typography-microcopy-md-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-microcopy-md-font-size clamp(0.75rem, 0.66rem + 0.44vw, 0.9375rem) via --font-size-200
  • --typography-microcopy-md-font-weight 500 via --typography-font-weight-medium → --font-weight-500
  • --typography-microcopy-md-letter-spacing 0.01em via --letter-spacing-default
  • --typography-microcopy-md-line-height 1 via --line-height-none
+4 more components esa-button, esa-pill, esa-range-slider, esa-sidebar-nav
.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-chip-group
.typography-microcopy-xs
  • --typography-microcopy-xs-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-microcopy-xs-font-size clamp(0.625rem, 0.56rem + 0.32vw, 0.75rem) via --font-size-100
  • --typography-microcopy-xs-font-weight 500 via --typography-font-weight-medium → --font-weight-500
  • --typography-microcopy-xs-letter-spacing 0.01em via --letter-spacing-default
  • --typography-microcopy-xs-line-height 1 via --line-height-none
+6 more components esa-app-shell, esa-button, esa-filter-pills, esa-kbd, esa-pill, esa-range-slider
.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-chip-group, 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
--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-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-label-color component forms group · +7 more #646464 via --color-content-default-secondary → --color-gray-11
--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-overlay-hover semantic every component rgba(0, 0, 0, 0.03)
--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-disabled semantic every component #8d8d8d via --color-gray-9
--color-content-utility-danger semantic every component #ce2c31 via --color-red-11
--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-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