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';

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>

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>

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

<esa-button-toggle label="Default"></esa-button-toggle>
<esa-button-toggle label="With hint" hint="Pick a layout"></esa-button-toggle>
<esa-button-toggle label="Disabled" disabled></esa-button-toggle>

Form participation

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

<form>
  <esa-button-toggle name="view" label="View"></esa-button-toggle>
  <button type="submit">Submit</button>
</form>

API

PropTypeDefaultDescription
options { value, label, icon?, ariaLabel? }[] [] 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.
label string '' Optional visible label above the group; becomes the radiogroup name.
hint string '' Optional helper text below the group.
size 'xs' | 'sm' | 'md' | 'lg' 'md' Control size (shared form scale).
required boolean false Marks the field required (adds an asterisk and aria-required).
disabled boolean false Disables every segment.

Events

EventTypeDefaultDescription
change CustomEvent<{ value: string }> Fired when the selection changes. Composed and bubbling; detail.value is the newly selected value.

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; hint is wired with aria-describedby.
  • 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. 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.

TokenTierResolves to
--focus-ring-color component #65ba74 via --color-border-focus--color-grass-8
--focus-ring-width component 2px
--form-bg component #fcfcfc via --color-surface--color-gray-1
--form-border-color component #cecece via --color-border--color-gray-7
--form-border-width component 1px
--form-font-size-lg component clamp(0.875rem, 0.77rem + 0.52vw, 1.125rem) via --type-size-300
--form-font-size-md component clamp(0.75rem, 0.66rem + 0.44vw, 0.9375rem) via --type-size-200
--form-font-size-sm component clamp(0.625rem, 0.56rem + 0.32vw, 0.75rem) via --type-size-100
--form-font-size-xs component clamp(0.5rem, 0.44rem + 0.3vw, 0.625rem) via --type-size-050
--form-height-lg component 48px
--form-height-md component 40px
--form-height-sm component 32px
--form-height-xs component 28px
--form-help-color component #838383 via --color-text-muted--color-gray-10
--form-label-color component #646464 via --color-text-secondary--color-gray-11
--form-padding-x-lg component 1rem via --spacing-400
--form-padding-x-md component 0.75rem via --spacing-300
--form-padding-x-sm component 0.625rem via --spacing-250
--form-padding-x-xs component 0.5rem via --spacing-200
--form-radius-lg component 0.5rem via --radius-200
--form-radius-md component 0.5rem via --radius-200
--form-radius-sm component 0.25rem via --radius-100
--form-radius-xs component 0.25rem via --radius-100
--color-danger semantic #e5484d via --color-red-9
--color-disabled-text semantic #8d8d8d via --color-gray-9
--color-hover-overlay semantic rgba(0, 0, 0, 0.03)
--color-primary semantic #46a758 via --color-grass-9
--color-surface-sunken semantic #f0f0f0 via --color-gray-3
--color-text-primary semantic #202020 via --color-gray-12
--color-text-secondary semantic #646464 via --color-gray-11
--font-sans semantic 'DM Sans', sans-serif
--transition-fast semantic 150ms ease
--type-size-150 semantic clamp(0.6875rem, 0.61rem + 0.38vw, 0.875rem)
--font-weight-medium primitive 500
--font-weight-semibold primitive 550
--spacing-100 primitive 0.25rem
--spacing-150 primitive 0.375rem