@aarongustafson/form-multi-switch
v1.0.1
Published
Progressively enhance native radio groups into accessible light-DOM segmented controls.
Maintainers
Readme
<form-multi-switch> Web Component
Use <form-multi-switch> to give a group of radio buttons a segmented appearance.
Your <fieldset>, <legend>, <label> elements, and radio controls stay in light
DOM, so the browser still handles keyboard navigation, validation, form submission,
and reset.
You can wrap radios you’ve already written or let the component create them from attributes or JavaScript. If you need the control to work without JavaScript, start with the radio markup.
Documentation and demos
Read the documentation on GitHub Pages or try the examples:
- All examples: shipping choices, clothing and shoe sizes, custom styles, icons, languages, and scripts.
- Manual registration: import the class and register it yourself, with an esm.sh import example.
- Automatic registration: use the definition helper, with an unpkg import example.
To run the demos locally, follow the development instructions.
Installation
npm install @aarongustafson/form-multi-switchImport the class, then register the element:
import { FormMultiSwitchElement } from '@aarongustafson/form-multi-switch';
customElements.define('form-multi-switch', FormMultiSwitchElement);If you’d rather have the component register itself, import define.js instead:
import '@aarongustafson/form-multi-switch/define.js';The package root and form-multi-switch.js leave registration to you.
define.js also exports a defineFormMultiSwitch() helper you can call yourself.
Calling it again is fine, as long as another class hasn’t claimed the element
name.
If you serve the installed files directly, load the registration module from HTML instead:
<script
type="module"
src="./node_modules/@aarongustafson/form-multi-switch/define.js"
></script>Usage
Enhance your existing radios
Wrap your fieldset in <form-multi-switch>:
<form-multi-switch>
<fieldset>
<legend>Display density</legend>
<label>
<input type="radio" name="density" value="comfortable" checked />
Comfortable
</label>
<label>
<input type="radio" name="density" value="compact" />
Compact
</label>
<label>
<input type="radio" name="density" value="condensed" />
Condensed
</label>
</fieldset>
</form-multi-switch>Give your group a <legend> and each choice a text label. All the radios need to
sit inside the same <fieldset>, share a name, and belong to the same form (if
you’re using one). Give each choice its own value.
You can wrap each <input> in its <label>, as above, or connect them with for
and id. Keep links and other interactive controls out of the labels.
Generate controls from attributes
If your control only needs to work with JavaScript, you can describe it with attributes instead:
<form-multi-switch
name="density"
label="Display density"
choices='[
{"label":"Comfortable","value":"comfortable"},
{"label":"Compact","value":"compact"},
{"label":"Condensed","value":"condensed"}
]'
value="comfortable"
></form-multi-switch>Give choices a JSON array with a text label and value for each choice.
Add "disabled": true to make a choice unavailable. Use JSON booleans (true
or false), not strings.
If you translate the labels, give each choice an explicit value so changing the language doesn’t change what your form submits.
The component creates the markup for you. Labels only support plain text, not
HTML. If you’ve already supplied a <fieldset>, its markup takes precedence:
the component ignores the choices, name, label, and value attributes.
Generate controls with JavaScript
If your choices come from JavaScript, you can also pass them to the component
using configure():
<form-multi-switch id="density"></form-multi-switch>await customElements.whenDefined('form-multi-switch');
const control = document.querySelector('#density');
control.configure({
name: 'density',
label: 'Display density',
choices: [
{ label: 'Comfortable', value: 'comfortable' },
{ label: 'Compact', value: 'compact' },
{ label: 'Condensed', value: 'condensed' },
],
value: 'comfortable',
});
await control.ready;configure() checks your configuration before making changes. You can call it
again to replace a group it created, including that group’s reset defaults. It
won’t replace a <fieldset> you wrote yourself.
Leave out value, or use null, to start without a selection. disabled,
readOnly, required, and iconOnly default to false. If the control needs
its own script, set the script attribute or property separately.
Attributes and properties
| Attribute | Property | What you control |
| ----------- | ---------- | ------------------------------------------------------------------------------------------------------- |
| name | name | Generated radios’ submission name. |
| label | label | Generated <legend> text. |
| choices | — | Choices as a JSON array. Use configure() to change the group’s configuration in one call. |
| value | value | Attribute: selection and reset default for generated controls. Property: live selection in either mode. |
| disabled | disabled | Disable the whole group without clearing its selection. |
| readonly | readOnly | Keep the group enabled and selected, but prevent users from changing the selection. |
| required | required | Whether the group requires a selection. |
| icon-only | iconOnly | Visually hide the choice text after enhancement. |
| script | script | URL of a JavaScript module to load before showing the segmented control. |
| — | state | Read-only status: pending, loading, ready, or error. |
| — | ready | Promise that resolves when the current setup attempt finishes. |
Use name and label to configure a group the component creates. For radios
you’ve written yourself, change the <input> names and <legend> in your markup.
With your own <fieldset>, these properties return its radio name and <legend>,
even if you’ve set conflicting attributes on the component. The choices,
selection, and reset defaults also come from your markup. Changing the ignored
attributes doesn’t change the group or rerun your script.
disabled, readonly, required, icon-only, and script work with either
authoring mode. A disabled group keeps its selection but cannot be changed by
users and is omitted from form submission. Re-enabling it makes the retained
value available for submission again. A read-only group remains enabled, keeps
its selection, and submits its value.
An individually disabled radio can also stay checked, just like a native
radio. Users can choose another enabled option, but cannot select the disabled
option again until it is re-enabled. Application code can still change checked
state through value; disabled radios remain excluded from submission.
Earlier versions cleared selection when a group was disabled. The component
now preserves it. To clear a selection deliberately, set control.value = null.
As with other HTML boolean attributes, presence is what counts:
disabled="false" still disables the group. Remove the attribute to turn it off.
Update the current selection
control.value = 'compact';
control.value = null; // Clear the selection.Read value to get the selected value, or null if nothing’s selected. Set it
to change the selection without changing what a form reset will restore. This
doesn’t fire interaction events.
Use a string that matches one of your choices, or null to clear the selection.
An unknown value throws a RangeError; other types throw a TypeError.
For generated controls, setting the value attribute changes both the
selection and its reset default. With your own <fieldset>, the component ignores
that attribute and uses the radios’ checked state instead. You can still set
the value property to change the live selection in either mode.
Removing the attribute from a generated group clears both its selection and reset default.
After a form reset, selected styling and the interaction-event baseline update in a task after the browser restores the radios. A canceled reset leaves them unchanged.
The attribute doesn’t change as someone uses the control; read the property when you need the current value.
Update your markup
You can add, remove, or reorder choices and change their labels, values, names, or form associations. The component watches your markup and checks the group again when it changes. If you’ve supplied a script, it runs that script’s initializer again, too.
Changes to disabled, checked, and required attributes update the styling
without rerunning your script. That includes disabling an ancestor fieldset.
Setting input.checked is different: it changes a property, not an attribute,
so a MutationObserver won’t see it. Use control.value to change the selection,
or dispatch an input event after changing the input property to update the
styling.
Retry or refresh explicitly
// Force revalidation and rerun the behavior initializer.
await control.refresh();Call refresh() if you need to retry a script or rerun setup yourself. The
component needs to be in the document first.
Markup updates happen asynchronously. Listen for form-multi-switch:ready when
you need to do something after the updated group is ready.
Events
Listen for selection changes when you want to update another part of your page:
control.addEventListener('form-multi-switch:change', (event) => {
const { value, previousValue, index } = event.detail;
updateDensity(value);
});| Event | When it fires | Detail |
| ---------------------------- | -------------------------------------------------------------------- | --------------------------------- |
| form-multi-switch:activate | Someone activates a choice, even if it’s already selected. | { value, previousValue, index } |
| form-multi-switch:change | Someone changes the selection, including with the keyboard. | { value, previousValue, index } |
| form-multi-switch:ready | The control finishes setting up. | { value } |
| form-multi-switch:error | Something goes wrong with configuration, loading, setup, or cleanup. | { error, phase } |
These events bubble, cross shadow boundaries, and aren’t cancelable. The radios
still fire their native input and change events.
An activate event doesn’t promise that the selection changed: another listener
can cancel the native click. Calling a radio’s .click() can also fire
interaction events, but setting properties or resetting the form won’t.
The custom interaction events start firing once the component is ready.
Behavior modules
If your switch needs a script to do its job, point script at that JavaScript
module. Your radios keep their usual appearance until the module has loaded
and finished setting up:
<form-multi-switch script="./density-behavior.js">
<!-- Your fieldset and radios -->
</form-multi-switch>You can use script with your own radio markup, attribute configuration, or
configure(). You don’t need a separate <script> element to load the module.
Export an initialize() function to connect your switch to the rest of the
page. It receives the switch and an AbortSignal you can use with event listeners:
export function initialize(control, { signal }) {
control.addEventListener(
'form-multi-switch:change',
(event) => {
document.documentElement.dataset.density = event.detail.value;
},
{ signal },
);
document.documentElement.dataset.density = control.value;
}If setup takes time, return a promise or make initialize() async. The component
will wait for it before changing the control’s appearance. Without an initializer,
it waits for the module itself to finish running.
Use the signal to cancel your work when the switch leaves the document or needs
to set up again. You can also return a cleanup function (directly or through the
promise) for anything the signal doesn’t handle. The cleanup function itself
must be synchronous. The browser caches the module, but each switch gets its
own call to initialize().
Relative URLs resolve against your document’s base URL. Only load scripts you
trust, using HTTP or HTTPS and a URL your Content Security Policy permits.
Cross-origin modules need CORS permission, too. data:, javascript:, and blob:
URLs aren’t accepted.
Readiness and failure
try {
await control.ready;
} catch (error) {
console.error('The density control could not initialize.', error);
}If your application needs to show a failure message, supply your own translated text.
Read state for the current status: pending, loading, ready, or error.
You’ll find the same value in the element’s data-multi-switch-state attribute.
ready is a promise for the current setup attempt, so read it again after
reconfiguring the group, changing script, refreshing, or reconnecting the
element. If a new attempt replaces one in progress, the old promise rejects
with an AbortError.
If something goes wrong, you’ll get an error event and a message in the console.
Radios you’ve written stay usable without the segmented styling. Fixing invalid
markup or configuration triggers another attempt. For a failed script, call
refresh() to retry, or remove script to use the control without it.
Refreshing doesn’t bypass the browser’s module cache.
Styling
Override the component’s custom properties in your own CSS:
form-multi-switch {
--multi-switch-radius: 999px;
--multi-switch-padding: 0.6em 1em;
--multi-switch-border: #173f63;
--multi-switch-selected-background: #173f63;
--multi-switch-selected-color: #fff;
}CSS custom properties
| Property | Default | What you control |
| ------------------------------------ | ---------------------- | ---------------------------------------------------------------------------------------- |
| --multi-switch-radius | 0.5rem | Rounding at the group’s endpoints. |
| --multi-switch-padding | 0.5em 0.85em | Padding inside each segment. |
| --multi-switch-border | CanvasText | Shared segment boundaries. |
| --multi-switch-background | Canvas | Unselected background. |
| --multi-switch-color | CanvasText | Unselected text. |
| --multi-switch-selected-background | CanvasText | Selected background; defaults to Canvas in forced colors. |
| --multi-switch-selected-color | Canvas | Selected text; defaults to CanvasText in forced colors. |
| --multi-switch-outline | 3px solid CanvasText | Complete outer focus outline; the default color comes from --multi-switch-focus-color. |
| --multi-switch-outline-offset | 3px | Distance between the focus outline and the segment. |
| --multi-switch-focus-color | CanvasText | Default outline color, used when --multi-switch-outline is unset. |
Unselected choices use CanvasText on Canvas. The selected choice reverses
that pair for a distinct filled segment, with no default underline in ordinary
color modes. In forced colors, it uses CanvasText on Canvas and adds a
three-pixel underline so selection stays visible when fills flatten.
The outer focus ring is separate from selection.
These defaults replace the previous HighlightText / Highlight selection
pair and Highlight focus color. Existing custom-property overrides still
work. Set foreground and background overrides together, and check the
forced-color selection marker against your selected surface. That marker uses
the choice’s ::after pseudo-element; keep an equivalent visible cue if you
replace it. The underline restyling demo is an intentional author override and
still shows an underline in ordinary color modes.
The default focus ring is a solid three-pixel outline with a three-pixel
offset. Its color comes from --multi-switch-focus-color. In forced colors,
the default uses CanvasText instead. Setting --multi-switch-outline
replaces the whole outline, including its color; it takes precedence over
the existing color hook. Both outline properties also apply in forced colors,
with automatic browser color adjustment still enabled.
Customize the focus ring
Set the outline properties on the component or an ancestor. The component
maps [data-focus] to the associated label and draws its ring only when the
radio matches :focus-visible. This works with wrapping and separate labels;
you don’t need selectors for each markup pattern. The radio keeps focus, so
don’t give the label a tab stop.
Outlines don’t inherit from a global :focus-visible rule, but you can share
a custom property with your site’s focus styles:
:root {
--site-focus-ring: 4px dashed CanvasText;
}
:focus-visible {
outline: var(--site-focus-ring);
}
form-multi-switch {
--multi-switch-outline: var(--site-focus-ring);
--multi-switch-outline-offset: 4px;
}You can request a browser-styled ring
with --multi-switch-outline: auto CanvasText. This doesn’t promise the exact ring painted
on a native button. There’s no standard focus-ring system color:
SelectedItem describes selection, not keyboard focus. Browsers may use their
own painted color for an auto outline, even when you specify CanvasText.
The component keeps a solid default because browser-styled rings can have
insufficient contrast on these surfaces.
See the custom focus demo
for a code/live example. Check your ring against its surrounding surface in
light, dark, and forced-color modes. Computed outline colors don’t prove the
painted contrast of a browser-styled auto ring.
Layout and styling hooks
The choices sit in a row when the component has at least 20rem of space.
Below that, they stack into a column. This follows the space available to the
component, not the width of your viewport. The component uses a size container
named multi-switch for this layout.
In a row, the default styles size choices based on their content and share the
remaining space. Longer labels get wider segments rather than crowding into
the padding of equal-width segments. Use --multi-switch-padding to adjust the
padding inside each choice. If space is still tight, text can wrap within the
segment rather than spill into its padding.
See the restyling demos for a branded pill and an
underline treatment, with CSS beside each live example. Put your own styles in
your page’s stylesheet. The defaults use a cascade layer named
form-multi-switch, so your unlayered rules take precedence without
!important, regardless of load order.
For a longer list, you might prefer a grid. The shoe-size example shows how to lay one out and give each choice its own border.
If your Content Security Policy blocks inline styles, load the exported
form-multi-switch.css with a <link id="form-multi-switch-styles"> before
registering the component. Or put the defaults in a <style> with that ID and
your approved nonce. The component will use that stylesheet instead of adding
its own; reserve that ID for the component’s styles.
For more control, use these attributes in your selectors once the component is ready:
| Hook | Element or state |
| ----------------------------- | ---------------------------------------------------------------------------------------- |
| [data-multi-switch-state] | Host initialization state. |
| [data-multi-switch-group] | Fieldset. |
| [data-multi-switch-input] | Retained native radio. |
| [data-multi-switch-choice] | Associated visible label. |
| [data-multi-switch-label] | Choice text container. |
| [data-value="compact"] | A choice label with that radio value. |
| [data-first], [data-last] | First and last choices in radio DOM order. |
| [data-selected] | Checked choice. |
| [data-disabled] | Effectively disabled choice. |
| [data-focus] | Choice whose radio currently has focus; the default ring also requires :focus-visible. |
data-first and data-last identify the ends of the group even when you’ve
wrapped each choice in another element. The default styles handle labels on
their own or one wrapper per choice. If you put the whole list inside another
container, give that container your own flex or grid layout.
You can use these attributes to style the control, but leave setting them to the component.
Use icons without losing names
To show icons instead of text, keep your labels and add icon-only:
<form-multi-switch
name="appearance"
label="Appearance"
choices='[
{"label":"Light","value":"light"},
{"label":"Dark","value":"dark"}
]'
value="light"
icon-only
></form-multi-switch>Use each choice’s data-value to give it an icon:
form-multi-switch[icon-only] [data-multi-switch-choice]::before {
content: '';
inline-size: 1.25em;
block-size: 1.25em;
background-color: currentColor;
mask: var(--choice-icon) center / contain no-repeat;
}
form-multi-switch [data-value='light'] {
--choice-icon: url('/icons/sun.svg');
}
form-multi-switch [data-value='dark'] {
--choice-icon: url('/icons/moon.svg');
}If you’re calling configure(), use iconOnly: true with the same CSS. Give
every choice an icon before turning on icon-only presentation. If the icons
aren’t likely to be familiar, keep the text visible.
When you’re writing the labels yourself, you can use inline SVG or other
decorative markup with aria-hidden="true" instead. Put the text in a
data-multi-switch-label span if you want to choose exactly what gets hidden:
<label>
<input type="radio" name="appearance" value="light" />
<svg aria-hidden="true" focusable="false" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="5"></circle>
</svg>
<span data-multi-switch-label>Light</span>
</label>Leave the text label available to assistive technology: don’t use display: none,
hidden, or aria-hidden on it. The component’s styles hide it visually once
the switch is ready, while keeping it as the radio’s accessible name.
CSS-mask icons can disappear in forced colors. The demo keeps automatic color adjustment and shows the text labels instead:
@media (forced-colors: active) {
form-multi-switch[icon-only] [data-multi-switch-choice]::before {
display: none;
}
form-multi-switch[icon-only] [data-multi-switch-label] {
position: static;
inline-size: auto;
block-size: auto;
margin: 0;
overflow: visible;
clip-path: none;
white-space: normal;
}
}This example assumes the icons use ::before, as above. Check your own icon
markup and any downstream theme overrides in forced colors, too.
Accessibility and progressive enhancement
- Keep a meaningful
<legend>for the group and<label>text for every choice. - Let the radios do their job. You don’t need
role="switch"or replacement buttons. - Keep a visible focus indicator when you customize the design. The default
outer ring follows the radio’s
:focus-visiblestate, so keyboard users get a focus cue without adding one to ordinary mouse or touch selections. - Check your colors, zoomed layout, right-to-left layout, forced colors, and whether people will understand your icons.
- Write your own radios when the control needs to work without JavaScript.
Groups created from attributes or
configure()need JavaScript to exist.
Translation
Write the labels and legend in your page’s language, whether you’re using
markup, attributes, or configure(). Give each choice a value that won’t change
when you translate its label. You supply all the visible text, including any
messages or interface your script adds.
Browser support
The component uses Custom Elements, ES modules, AbortController, MutationObserver,
and modern CSS, including cascade layers, size container queries, logical
properties, :has(), :focus-visible, and clip-path. It doesn’t include
polyfills for older browsers.
Development
Clone the repository and run:
npm install
npm run test:run
npm run test:coverage
npm run test:types
npx playwright install chromium firefox webkit
npm run test:browser
npm run lint
npm run formatServe the repository over HTTP to try the demos and browser tests:
npx serve .Open the demos or
the browser tests, using the port
reported by the server. The browser tests
cover native validation, cancellation, focus, and submission.
Add ?native-reset to the browser-test URL and activate Reset delivery when
it appears to check native reset-button timing.
The unit suite uses Happy DOM. The Playwright suite runs the existing browser page and checks rendered colors, selection cues, keyboard interaction, and demo layouts in Chromium, Firefox, and WebKit. It serves local fixtures through request routing, so it doesn’t need a running server. Forced-color emulation checks run in Chromium only; they don’t replace real platform testing.
The browser page covers native behavior that a DOM emulator can’t reliably reproduce. Also test real keyboard navigation and screen-reader interaction with your target browsers and assistive technologies.
See Contributing for the test-first workflow and pull request guidelines.
