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. Smaller horizontal padding keeps decimal sizes on one line.
<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 { --multi-switch-padding: 0.5em 0.25em; }
.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-inline-start: 0.25rem;
inset-block-start: 50%;
inline-size: calc(100% - 0.5rem);
border-block-start: 2px solid currentColor;
transform: rotate(-35deg);
pointer-events: none;
}
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;
}
Example: Customize the keyboard focus ring
Set --multi-switch-outline for the whole
focus ring and
--multi-switch-outline-offset
for its distance from the segment. The component maps
[data-focus] to the associated label and
shows the ring when the radio matches
:focus-visible. No extra label selector is
needed, and the radio still handles focus and selection.
These properties work with wrapping and separate labels, including in forced colors. You can share a custom property with your site’s focus rule, as shown here. Use Tab to reach the group and arrow keys to change its selection.
For a browser-styled ring, set
--multi-switch-outline: auto CanvasText.
Browsers may use their own painted color for an
auto ring, so check its rendered contrast.
<form-multi-switch class="custom-focus">
<fieldset>
<legend>Result order</legend>
<label><input type="radio" name="result-order"
value="recommended" checked>Recommended</label>
<label><input type="radio" name="result-order"
value="newest">Newest</label>
<label><input type="radio" name="result-order"
value="oldest">Oldest</label>
</fieldset>
</form-multi-switch>
:root {
--demo-focus-ring: 4px dashed CanvasText;
}
.custom-focus {
--multi-switch-outline: var(--demo-focus-ring);
--multi-switch-outline-offset: 4px;
}
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 currentColor;
}
.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 outline follows the selected radio. Choose another option to see it slide; reduced-motion preferences disable the transition. In forced colors, the selected choice also keeps an underline.
<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);
box-sizing: border-box;
border: 2px solid var(--multi-switch-selected-color, CanvasText);
background: var(--multi-switch-selected-background, Canvas);
content: "";
transition: transform 300ms ease;
}
.sliding-highlight [data-multi-switch-choice] {
grid-row: 2;
z-index: 1;
margin: 0;
border-color: transparent;
background: transparent;
color: var(--multi-switch-color, CanvasText);
box-shadow: none;
}
.sliding-highlight [data-multi-switch-choice][data-selected] {
color: var(--multi-switch-selected-color, CanvasText);
}
.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. In forced colors, this demo shows the text
instead of the masked icons.
<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");
}
@media (forced-colors: active) {
.icons [data-multi-switch-choice]::before { display: none; }
.icons[icon-only] [data-multi-switch-label] {
position: static;
inline-size: auto;
block-size: auto;
margin: 0;
overflow: visible;
clip-path: none;
white-space: normal;
}
}
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
PDF starts checked but disabled. CSV and JSON remain selectable: choose either to move away from PDF, then try to select PDF again. The native disabled input prevents users from returning to it.
<form-multi-switch>
<fieldset>
<legend>Export format</legend>
<label><input type="radio" name="export"
value="csv">CSV</label>
<label><input type="radio" name="export"
value="json">JSON</label>
<label><input type="radio" name="export"
value="pdf" disabled checked>PDF</label>
</fieldset>
</form-multi-switch>
Example: Disabled group
A disabled group keeps its checked value but cannot be
changed and is omitted from form submission. The
selection remains available when the group is
re-enabled. Use readonly instead when the
current value should remain enabled and submitted.
<form-multi-switch>
<fieldset disabled>
<legend>Unavailable archive formats</legend>
<label><input type="radio" name="archive"
value="zip" checked>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.