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>
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>
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));
}
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>
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>
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>
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>
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;
}
- 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();
}
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.');
}
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.