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.
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
| Prop | Type | Default | Description |
|---|---|---|---|
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
| Event | Type | Default | Description |
|---|---|---|---|
change | CustomEvent<{ value: string }> | — | Fired when the selection changes. Composed and bubbling; detail.value is the newly selected value. |
Accessibility
- Renders
role="radiogroup"wrappingrole="radio"buttons witharia-checkedreflecting selection. - Roving tabindex: only the selected (or first) segment is tab-reachable; Arrow keys move selection through the group, wrapping at the ends.
HomeandEndjump to the first and last segments; Enter and Space select the focused segment.- The
labelbecomes the group's accessible name viaaria-labelledby;hintis wired witharia-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.
| Token | Tier | Resolves 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 |