Accessible toggle-switch control for Frost UI with configurable labels, semantic styles, sizes, CSS transitions, mouse and touch dragging, keyboard operation, and RTL support.
- Native checkbox remains the form control and source of truth
- Click, Space, Enter, mouse drag, and touch drag interaction
- Five Frost UI sizes with configurable text, semantic classes, and widths
- Frost UI v4 light, dark, system, focus, disabled, and RTL presentation
- Accessible switch role, state, required state, disabled state, and label association
- Existing-instance reuse with frozen resolved options
- Native
Switchclass andswitchfQuery plugin - Reversible disposal that restores the input's original visibility and
tabindex - Prebuilt ESM and UMD bundles with source maps
- Expanded and minified component CSS with source maps
- JSDoc-powered IntelliSense
Install Switch with its Frost UI v4 and fQuery v5 peers:
npm i @fr0st/ui-switch @fr0st/ui @fr0st/queryThe package root resolves to the compiled ESM bundle. Import both required stylesheets and the default component export:
import '@fr0st/ui/dist/frost-ui.min.css';
import '@fr0st/ui-switch/dist/frost-ui-switch.min.css';
import Switch from '@fr0st/ui-switch';
const notifications = Switch.init(
document.querySelector('#notifications'),
{
offText: 'Muted',
onText: 'Enabled',
},
);@fr0st/ui and @fr0st/query are peer dependencies so the component shares the application's UI and fQuery instances. The package root, dist/*, and src/* are available through package exports.
Switch requires a browser DOM or a compatible DOM environment configured through fQuery. Server-rendered applications should load the component on the client.
The ESM bundle imports @fr0st/ui and @fr0st/query. Frost UI and fQuery also require @fr0st/core, so map all three dependencies when loading the bundle directly in a browser:
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@fr0st/ui@latest/dist/frost-ui.min.css">
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@fr0st/ui-switch@latest/dist/frost-ui-switch.min.css">
<script type="importmap">
{
"imports": {
"@fr0st/core": "https://cdn.jsdelivr.net/npm/@fr0st/core@latest/dist/frost-core.esm.min.js",
"@fr0st/query": "https://cdn.jsdelivr.net/npm/@fr0st/query@latest/dist/fquery.esm.min.js",
"@fr0st/ui": "https://cdn.jsdelivr.net/npm/@fr0st/ui@latest/dist/frost-ui.esm.min.js"
}
}
</script>
<script type="module">
import Switch from 'https://cdn.jsdelivr.net/npm/@fr0st/ui-switch@latest/dist/frost-ui-switch.esm.min.js';
Switch.init(document.querySelector('#notifications'));
</script>Load Frost UI's all-in-one bundle before Switch. The UI bundle supplies both the UI and fQuery globals expected by the component:
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@fr0st/ui@latest/dist/frost-ui.min.css">
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@fr0st/ui-switch@latest/dist/frost-ui-switch.min.css">
<script src="https://cdn.jsdelivr.net/npm/@fr0st/ui@latest/dist/frost-ui-bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@fr0st/ui-switch@latest/dist/frost-ui-switch.min.js"></script>
<script>
const notifications = UI.Switch.init(
document.querySelector('#notifications'),
);
</script>The UMD bundle adds Switch to the existing globalThis.UI object. It expects globalThis.UI and globalThis.fQuery to exist before it loads. If the non-bundled Frost UI build is used instead, load fQuery, Frost UI, and Switch in that order.
Do not load the separate fQuery script when using frost-ui-bundle.js or frost-ui-bundle.min.js.
Start with a normal checkbox and an explicit or wrapping label. Switch inserts the visible control immediately before the input and visually hides the original checkbox while keeping it synchronized for forms:
<label for="notifications">Notifications</label>
<input
id="notifications"
name="notifications"
type="checkbox"
value="yes"
checked>import Switch from '@fr0st/ui-switch';
const notifications = Switch.init(
document.querySelector('#notifications'),
{
offStyle: 'text-bg-secondary',
offText: 'OFF',
onStyle: 'text-bg-success',
onText: 'ON',
size: 'lg',
},
);
console.log(notifications.getState()); // trueCalling Switch.init() again for the same input returns its existing instance. Dispose the current instance before reinitializing the input with different options.
Options are resolved in this order:
Switch.defaults- The input's
data-ui-*attributes - Options passed to
Switch.init()
Resolved instance.options are frozen.
Switch.defaults and Switch.classes are static properties defined on the class. Set application-wide defaults before initializing components:
Switch.defaults.size = 'sm';
Switch.defaults.duration = 240;Changes to defaults apply to newly created instances. Switch.classes contains the structural and state class names used by the component; custom names need matching CSS and should be configured before initialization.
| Option | Type | Default | Description |
|---|---|---|---|
animate |
boolean |
true |
Transition between checked and unchecked positions. |
dividerStyle |
string |
'bg-body-tertiary' |
Apply one or more Frost UI classes to the center divider. |
dividerWidth |
number | null |
null |
Set the divider width in pixels. null derives half the label width. |
duration |
number |
500 |
Set the full CSS transition duration in milliseconds. Partial movement scales the duration. |
labelWidth |
number | null |
null |
Set both label widths in pixels. null measures the wider label. |
offStyle |
string |
'text-bg-secondary' |
Apply one or more Frost UI classes to the unchecked label. |
offText |
string |
'OFF' |
Set the unchecked label text. |
onStyle |
string |
'text-bg-primary' |
Apply one or more Frost UI classes to the checked label. |
onText |
string |
'ON' |
Set the checked label text. |
size |
'xs' | 'sm' | 'md' | 'lg' | 'xl' |
'md' |
Select the component font size and inline padding. |
const switchControl = Switch.init(node, {
animate: true,
dividerStyle: 'bg-warning',
dividerWidth: 16,
duration: 240,
labelWidth: 88,
offStyle: 'text-bg-danger',
offText: 'STOP',
onStyle: 'text-bg-success',
onText: 'GO',
size: 'lg',
});Invalid, non-positive, or absent labelWidth values fall back to the wider measured label. Invalid, non-positive, or absent dividerWidth values use half the resolved label width. Invalid or non-positive durations update the component immediately.
If Switch is initialized while hidden, automatic sizing waits until the control becomes measurable. A temporary resize observer refreshes the layout when it appears, then disconnects. Explicit positive labelWidth values are applied immediately.
All options can be supplied through data-ui-* attributes:
| Attribute | Example |
|---|---|
data-ui-animate |
data-ui-animate="false" |
data-ui-divider-style |
data-ui-divider-style="bg-warning" |
data-ui-divider-width |
data-ui-divider-width="16" |
data-ui-duration |
data-ui-duration="240" |
data-ui-label-width |
data-ui-label-width="88" |
data-ui-off-style |
data-ui-off-style="text-bg-danger" |
data-ui-off-text |
data-ui-off-text="STOP" |
data-ui-on-style |
data-ui-on-style="text-bg-success" |
data-ui-on-text |
data-ui-on-text="GO" |
data-ui-size |
data-ui-size="lg" |
<input
id="availability"
type="checkbox"
data-ui-toggle="switch"
data-ui-duration="240"
data-ui-off-style="text-bg-danger"
data-ui-off-text="BUSY"
data-ui-on-style="text-bg-success"
data-ui-on-text="FREE"
data-ui-size="lg">The component still needs to be initialized through the class or fQuery plugin. The demo uses data-ui-toggle="switch" as a shared initialization selector:
$('[data-ui-toggle="switch"]').switch();The data-ui-toggle attribute does not initialize Switch by itself.
| Method | Returns | Description |
|---|---|---|
Switch.init(node, options?) |
Switch |
Return the existing instance for an input or create one. |
disable() |
void |
Disable the checkbox, cancel any active drag, and make the rendered switch unavailable and unfocusable. |
dispose() |
void |
Remove generated markup and events, unregister component state, and restore the original input. |
enable() |
void |
Remove the checkbox's disabled attribute and refresh the rendered disabled state. |
getState() |
boolean |
Return whether the original checkbox is checked. |
setState(checked) |
void |
Normalize the value to a boolean and move to that state. |
toggleState() |
void |
Move to the opposite target state. |
switchControl.setState(true);
// With animation enabled, getState() updates when the transition completes.
switchControl.toggleState();
switchControl.disable();
switchControl.enable();
switchControl.dispose();An instance also exposes its original input as instance.node and its frozen resolved configuration as instance.options. Both become null after disposal.
Calling disable() during a drag restores the displayed position to the checkbox's current state without emitting a change event. Further movement and release from that drag do not toggle the checkbox.
dispose() restores the input's original visually-hidden state, aria-hidden, and tabindex, preserves its current checked and disabled state and unrelated classes, and removes generated label IDs only if the application has not changed them. The input can then be initialized again with new options.
Switch emits one namespaced fQuery event from the original checkbox after a component-driven state change completes:
| Event | Description |
|---|---|
change.ui.switch |
The checked state changed through click, keyboard, drag, setState(), or toggleState(). |
import $ from '@fr0st/query';
$.addEvent(
'#notifications',
'change.ui.switch',
(event) => {
console.log(event.currentTarget.checked);
},
);The underlying event type is change; fQuery exposes event.namespace as ui.switch. Setting the current state again does not emit another event. When transitions are enabled, the checkbox, ARIA state, and event update when the transition reaches its final position.
A native checkbox change event also moves the rendered control to match the input when no drag is active. Assigning input.checked alone does not notify Switch; use setState() or dispatch a native change event after assigning it.
After a native form reset, Switch defers its refresh until the checkbox has reset, then restores the displayed and ARIA state without animating or emitting a change event. The refresh cancels active dragging or animation. Canceled resets leave the current interaction unchanged, and an explicit state change requested after the reset takes precedence. Inputs associated with a form through the form attribute are also supported.
Importing Switch registers switch on fQuery.QuerySet:
import $ from '@fr0st/query';
import '@fr0st/ui-switch';
const switchControl = $('#notifications').switch({
onText: 'Enabled',
});
$('#notifications').switch('setState', true);
const checked = $('#notifications').switch('getState');
$('#notifications').switch('toggleState');
$('#notifications').switch('disable');
$('#notifications').switch('enable');
$('#notifications').switch('dispose');Pass an options object to initialize every matched input, or pass a public method name followed by its arguments. The first component or method result is returned.
- The rendered control uses
role="switch",aria-checked,aria-required, andaria-disabled. - Explicit labels, wrapping labels, and existing
aria-labelledbyreferences contribute to the rendered control's accessible name. - An input
aria-labelis copied when no label references are available. - Labels without IDs receive temporary generated IDs while the component is active.
- The rendered control enters the tab order while the original checkbox becomes visually hidden and receives
aria-hidden="true"andtabindex="-1"to expose a single accessible control. - Space and Enter toggle the focused switch. Repeated keydown events are ignored.
- Disabled switches leave the tab order and ignore keyboard, click, mouse, and touch interaction.
- The original checkbox remains the submitted form field and preserves native
checked,required, anddisabledbehavior.
Applications remain responsible for a meaningful visible label, instructions, validation feedback, and sufficient contrast when replacing the default semantic classes.
Switch combines its component stylesheet with Frost UI v4 CSS custom properties, focus-ring tokens, semantic text/background utilities, and disabled opacity. Frost UI follows the user's preferred color scheme by default. Set data-ui-theme="light" or data-ui-theme="dark" on the document or an ancestor to select a theme explicitly:
<section data-ui-theme="dark">
<label for="dark-switch">Dark theme switch</label>
<input id="dark-switch" type="checkbox">
</section>Movement uses CSS transform transitions and respects prefers-reduced-motion. Initial checked state is rendered directly without an entrance transition.
Normal document and ancestor direction is respected. A dir attribute placed directly on the original input is also copied to the rendered control:
<input id="rtl-switch" type="checkbox" dir="rtl">In RTL layouts, the visual positions and physical drag direction are mirrored while checked state semantics remain unchanged.
Use Node.js matching ^20.19.0 || ^22.13.0 || >=24. Install dependencies with npm ci, then install Playwright browsers with npx playwright install --with-deps.
npm test
npm run lint
npm run buildnpm test rebuilds JavaScript and CSS, then runs the Playwright suite in Chromium, Firefox, and WebKit. npm run test:browser runs the suite against the existing bundles, so rebuild after changing source files.
After building, npm run test:coverage runs Chromium tests and writes coverage reports to coverage/.
npm run test:headed and npm run test:ui also use the existing bundles and open headed browsers or the Playwright UI.
npm run lint:sass:unused checks for unused Sass variables.
Frost UI Switch is released under the MIT License.