<form-multi-switch>

Present a native radio group as a segmented control without replacing its inputs. See the README for installation and API documentation.

Keyboard too: Use Tab to reach a group, arrow keys to change its selection, and Space to select.

Enhance your existing radios

Default styling example: Shipping choices

This shows the default styles, hoisted into the page from the component itself. I’ve included tools to inspect the selected value and show how it still respects the default selection when you reset it (restoring “Standard” shipping).

<form-multi-switch>
  <fieldset>
    <legend>Delivery speed</legend>
    <label><input type="radio" name="delivery"
      value="standard" checked>Standard</label>
    <label><input type="radio" name="delivery"
      value="express">Express</label>
    <label><input type="radio" name="delivery"
      value="overnight">Overnight</label>
  </fieldset>
</form-multi-switch>
Delivery speed

Submit to inspect the selected delivery value.

Multiple choice: Choose a size

Clothing sizes

Keep familiar size labels in a connected group. These native radios work before JavaScript loads, too.

<form-multi-switch>
  <fieldset>
    <legend>T-shirt size</legend>
    <label><input type="radio" name="shirt-size" value="S">S</label>
    <label><input type="radio" name="shirt-size" value="M">M</label>
    <label><input type="radio" name="shirt-size" value="L">L</label>
    <label><input type="radio" name="shirt-size" value="XL">XL</label>
  </fieldset>
</form-multi-switch>
T-shirt size

Wrapping group: Shoe sizes

Use your own grid for a longer list. Each size has its own border and rounded corners rather than pretending the wrapped rows form one continuous switch. A diagonal line marks disabled sizes as unavailable.

<form-multi-switch class="size-grid">
  <fieldset>
    <legend>Shoe size (US women’s)</legend>
    <ul>
      <li><label><input type="radio" name="shoe-size"
        value="6">6</label></li>
      <li><label><input type="radio" name="shoe-size"
        value="8.5" disabled>8.5
        <span class="visually-hidden"> (unavailable)</span>
      </label></li>
      <!-- Repeat for the available sizes. -->
    </ul>
  </fieldset>
</form-multi-switch>

.size-grid [data-multi-switch-group] { display: block; }
.size-grid ul {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(3.25rem, 1fr));
  gap: 0.5rem;
  padding: 0;
  margin: 0;
  list-style: none;
}
.size-grid [data-multi-switch-choice] {
  inline-size: 100%;
  margin: 0;
  border-radius: 0.4rem;
}
.size-grid [data-disabled]::after {
  content: '';
  position: absolute;
  inset: 0.25rem;
  pointer-events: none;
  background: linear-gradient(135deg,
    transparent calc(50% - 1px),
    currentColor calc(50% - 1px),
    currentColor calc(50% + 1px),
    transparent calc(50% + 1px));
}
Shoe size (US women’s)

Declarative Configuration: For JS-driven controls

Generate a group from attributes

Supply a name, accessible label, and JSON choices to have the component generate and manage the controls. If you are using this component in a JavaScript-driven UI, this approach allows you to declaratively define the options without manually creating the <input> elements.

<form-multi-switch
  name="billing"
  label="Billing frequency"
  choices='[
    {"label":"Monthly","value":"monthly"},
    {"label":"Quarterly","value":"quarterly"},
    {"label":"Annually","value":"annual"}
  ]'
  value="quarterly"
></form-multi-switch>

Pure JS example: Generate a multi-switch from application data

If you want to generate the multi-switch options dynamically, use configure().

This demo also shows how to update the selected value programmatically after the multi-switch has been configured.

<form-multi-switch id="js-density">
</form-multi-switch>

const control = document.querySelector('#js-density');
control.configure({
  name: 'density',
  label: 'Display density',
  choices: [
    { label: 'Comfortable', value: 'comfortable' },
    { label: 'Compact', value: 'compact' },
    { label: 'Condensed', value: 'condensed' }
  ],
  value: 'comfortable'
});

control.value = 'compact';

Adapt the design to your use case

The component supports custom properties for colors, padding, and corners. Use the light-DOM hooks when you want to change individual states. These examples change the presentation, not the radio behavior.

Resize the page to try the narrow layout. Below 20rem of available component width, choices stack as one connected control instead of wrapping into partial rows. First and last corners follow the new layout.

Example: Different colors and a pill outline

<form-multi-switch class="branded pill"
  name="report-period" label="Report period"
  choices='[
    {"label":"Day","value":"day"},
    {"label":"Week","value":"week"},
    {"label":"Month","value":"month"}
  ]'
  value="week"
></form-multi-switch>

.pill {
  --multi-switch-radius: 999px;
}

.branded {
  --multi-switch-border: #173f63;
  --multi-switch-background: #eef4ff;
  --multi-switch-color: #173f63;
  --multi-switch-selected-background: #173f63;
  --multi-switch-selected-color: #fff;
  --multi-switch-focus-color: #173f63;
}

Example: Underlined instead of a filled segment

<form-multi-switch class="underline"
  name="results" label="Show results"
  choices='[
    {"label":"All","value":"all"},
    {"label":"Unread","value":"unread"},
    {"label":"Saved","value":"saved"}
  ]'
  value="unread"
></form-multi-switch>

.underline {
  --multi-switch-radius: 0;
  --multi-switch-border: transparent;
  --multi-switch-selected-background: Canvas;
  --multi-switch-selected-color: CanvasText;
}

.underline [data-multi-switch-choice] {
  border-block-end: 2px solid GrayText;
}

.underline [data-multi-switch-choice][data-selected] {
  border-block-end: 4px solid currentColor;
  box-shadow: none;
  font-weight: 700;
}

Example: A highlight that slides between choices

A CSS-only highlight follows the selected radio. Choose another option to see it slide; reduced-motion preferences disable the transition.

<form-multi-switch class="sliding-highlight"
  name="layout" label="Reading layout"
  choices='[
    {"label":"List","value":"list"},
    {"label":"Grid","value":"grid"},
    {"label":"Table","value":"table"}
  ]'
  value="grid"
></form-multi-switch>

.sliding-highlight [data-multi-switch-group] {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
}

.sliding-highlight [data-multi-switch-group] > legend {
  grid-column: 1 / -1;
}

.sliding-highlight [data-multi-switch-group]::before {
  grid-area: 2 / 1;
  z-index: 0;
  border-radius: var(--multi-switch-radius, 0.5rem);
  background: var(--multi-switch-selected-background, Highlight);
  content: "";
  transition: transform 300ms ease;
}

.sliding-highlight [data-multi-switch-choice] {
  grid-row: 2;
  z-index: 1;
  margin: 0;
  border-color: transparent;
  background: transparent;
  box-shadow: none;
}

.sliding-highlight [data-value="list"] { grid-column: 1; }
.sliding-highlight [data-value="grid"] { grid-column: 2; }
.sliding-highlight [data-value="table"] { grid-column: 3; }

.sliding-highlight [data-multi-switch-group]:has(
  [data-value="grid"][data-selected]
)::before {
  transform: translateX(100%);
}

.sliding-highlight [data-multi-switch-group]:has(
  [data-value="table"][data-selected]
)::before {
  transform: translateX(200%);
}

.sliding-highlight [data-multi-switch-group]:dir(rtl):has(
  [data-value="grid"][data-selected]
)::before {
  transform: translateX(-100%);
}

.sliding-highlight [data-multi-switch-group]:dir(rtl):has(
  [data-value="table"][data-selected]
)::before {
  transform: translateX(-200%);
}

@media (prefers-reduced-motion: reduce) {
  .sliding-highlight [data-multi-switch-group]::before {
    transition: none;
  }
}

Prefer icons?

Example: System, light, and dark choices

Keep a text label for every radio and add decorative CSS masks. icon-only visually hides the text after enhancement; assistive technology can still read each label.

<form-multi-switch class="icons pill"
  name="appearance" label="Appearance preview"
  choices='[
    {"label":"System","value":"system"},
    {"label":"Light","value":"light"},
    {"label":"Dark","value":"dark"}
  ]'
  value="system" icon-only
></form-multi-switch>

.icons [data-multi-switch-choice]::before {
  content: "";
  inline-size: 1.4em;
  block-size: 1.4em;
  background: currentColor;
  mask: var(--choice-icon) center / contain no-repeat;
}

.icons [data-value="system"] {
  --choice-icon: url("./monitor.svg");
}
.icons [data-value="light"] {
  --choice-icon: url("./sun.svg");
}
.icons [data-value="dark"] {
  --choice-icon: url("./moon.svg");
}

Plays well with native form behavior

Example: Selection required

Try to submit without a selection to see native validation in play.

<form>
  <form-multi-switch required>
    <fieldset>
      <legend>Contact preference (required)</legend>
      <label><input type="radio" name="contact"
        value="email" required>Email</label>
      <label><input type="radio" name="contact"
        value="phone" required>Phone</label>
    </fieldset>
  </form-multi-switch>
  <button>Validate preference</button>
</form>
Contact preference (required)

Choose a preference before submitting.

Example: Disabled choice

CSV and JSON remain selectable. The native disabled input keeps PDF out of keyboard navigation and prevents its selection.

<form-multi-switch>
  <fieldset>
    <legend>Export format</legend>
    <label><input type="radio" name="export"
      value="csv" checked>CSV</label>
    <label><input type="radio" name="export"
      value="json">JSON</label>
    <label><input type="radio" name="export"
      value="pdf" disabled>PDF</label>
  </fieldset>
</form-multi-switch>
Export format

Example: Disabled group

A disabled group has no selected option, cannot be changed, and is omitted from form submission. Use readonly instead when the current value should remain selected and submitted.

<form-multi-switch>
  <fieldset disabled>
    <legend>Unavailable archive formats</legend>
    <label><input type="radio" name="archive"
      value="zip">ZIP</label>
    <label><input type="radio" name="archive"
      value="tar">TAR</label>
  </fieldset>
</form-multi-switch>
Unavailable archive formats

Example: Read-only group

A read-only group keeps its selected value, remains enabled, and can be submitted. Users can focus it but cannot change the selection.

<form-multi-switch readonly>
  <fieldset>
    <legend>Archive format</legend>
    <label><input type="radio" name="archive-readonly"
      value="zip" checked>ZIP</label>
    <label><input type="radio" name="archive-readonly"
      value="tar">TAR</label>
  </fieldset>
</form-multi-switch>
Archive format

Other languages (and directions)

Examples: French & Arabic

The component plays nicely with different languages and text directions.

<form-multi-switch lang="fr"
  name="densite" label="Densité"
  choices='[
    {"label":"Aérée","value":"comfortable"},
    {"label":"Compacte","value":"compact"},
    {"label":"Condensée","value":"condensed"}
  ]'
  value="comfortable"
></form-multi-switch>

<form-multi-switch dir="rtl" lang="ar" class="pill">
  <fieldset>
    <legend>حجم النص</legend>
    <label><input type="radio" name="text-size"
      value="small">صغير</label>
    <label><input type="radio" name="text-size"
      value="medium" checked>متوسط</label>
    <label><input type="radio" name="text-size"
      value="large">كبير</label>
  </fieldset>
</form-multi-switch>
حجم النص

Advanced usage: Connect to JavaScript

If your switch needs JavaScript to do its job, point the script attribute at that module. The component loads it and calls its initialize() function, passing in the switch and an AbortSignal you can use to clean up event listeners. Your radios keep their usual appearance until the module is ready.

Example: Switch between a list and a grid

This script reads the selected value and changes how the saved reports are displayed. You don’t need a separate <script> element to load it.

<form-multi-switch script="./view-behavior.js">
  <fieldset>
    <legend>Saved reports view</legend>
    <label><input type="radio" name="reports-view"
      value="list" checked>List</label>
    <label><input type="radio" name="reports-view"
      value="grid">Grid</label>
  </fieldset>
</form-multi-switch>
<ul data-view-preview>
  <li>Weekly sales</li>
  <li>Stock levels</li>
  <li>Returns</li>
  <li>Customer feedback</li>
</ul>

// view-behavior.js:
export function initialize(control, { signal }) {
  const preview = control.parentElement
    .querySelector('[data-view-preview]');
  const update = () => {
    preview.dataset.view = control.value;
  };
  control.addEventListener('form-multi-switch:change',
    update, { signal });
  update();
  return () => preview.removeAttribute('data-view');
}

[data-view-preview][data-view='grid'] {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 0.5rem;
}
Saved reports view
  • Weekly sales
  • Stock levels
  • Returns
  • Customer feedback

Interactive during boot

Example: Ordinary radios remain available while loading

This instance has a 800ms initialization delay. The radio controls remain usable while the module initializes. Once ready, the control changes to the multi-switch.

<form-multi-switch script="./density-behavior.js">
  <fieldset>
    <legend>Preview spacing</legend>
    <label><input type="radio" name="preview-density"
      value="comfortable" checked>Comfortable</label>
    <label><input type="radio" name="preview-density"
      value="compact">Compact</label>
  </fieldset>
</form-multi-switch>
<ul data-preview>
  <li>First saved report</li>
  <li>Second saved report</li>
  <li>Third saved report</li>
</ul>

// Your behavior module:
export function initialize(control, { signal }) {
  const preview = control.closest('section')
    .querySelector('[data-preview]');
  const updatePreview = () => {
    preview.style.lineHeight =
      control.value === 'compact' ? '1.2' : '2.4';
  };
  control.addEventListener('form-multi-switch:change',
    updatePreview, { signal });
  updatePreview();
}
Preview spacing

Loading preview behavior…

  • First saved report
  • Second saved report
  • Third saved report

Wrapped controls always fail to usable markup

Example: A deliberately failing JavaScript enhancement

This example intentionally rejects initialization. The radios keep their ordinary presentation and the error appears below (and in the console).

<form-multi-switch script="./failing-behavior.js">
  <fieldset>
    <legend>Results per page</legend>
    <label><input type="radio" name="page-size"
      value="10" checked>10</label>
    <label><input type="radio" name="page-size"
      value="25">25</label>
  </fieldset>
</form-multi-switch>

// failing-behavior.js:
export function initialize() {
  throw new Error('Demo initialization failure.');
}
Results per page

Waiting for the deliberately failing initializer…

Inspect interaction events

Example: Interaction events from this page

This example logs interaction events from the multi-switch controls on this page.

document.addEventListener(
  'form-multi-switch:change',
  (event) => {
    const { value, previousValue, index } = event.detail;
    console.log(value, previousValue, index);
  }
);

document.addEventListener(
  'form-multi-switch:activate',
  (event) => console.log(event.detail)
);
Interact with a control to inspect its events.