<form-multi-switch> Web Component

npm version CI status Minified + gzipped size License Type definitions

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:

To run the demos locally, follow the development instructions.

Installation

npm install @aarongustafson/form-multi-switch

Import 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

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 format

Serve 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.

License

MIT © Aaron Gustafson.