Switch
Switch represents one boolean setting that takes effect immediately. It is a real checkbox with switch semantics, so the browser keeps focus, Space and label activation, form submission, required validation, disabled behavior, and reset semantics in agreement with the visible state.
The common path is one Switch at the end of one labelled setting row. The application keeps the label, description, persistence, pending state, and error feedback in ordinary markup. There is no track, thumb, size, or colour API to configure.
Installation
One command detects Vue, React, or Svelte and installs one framework-native source file:
Run one command from a Boring Stack application. Klean detects the framework and conventional destination, then adds the framework-native source and its direct dependencies.
npx klean-ui add switch- No initializer or configuration file
- No framework, alias, or theme questions
- No Klean runtime dependency
The installation creates no initializer, provider, klean-ui.json, alias questionnaire, generated class helper, or Klean runtime dependency.
Usage
Vue
<script setup>
import { ref } from 'vue'
import Switch from '@/components/ui/switch/Switch.vue'
const publicRoadmap = ref(true)
</script>
<template>
<label
class="flex min-h-11 cursor-pointer items-center justify-between gap-6"
>
<span>
<span class="block font-medium">Public roadmap</span>
<span class="text-sm text-gray-500">
Show planned work to customers.
</span>
</span>
<Switch v-model="publicRoadmap" name="publicRoadmap" />
</label>
</template>
React
import { useState } from 'react'
import Switch from '@/components/ui/switch/Switch.jsx'
export default function PublicRoadmapSetting() {
const [publicRoadmap, setPublicRoadmap] = useState(true)
return (
<label className="flex min-h-11 cursor-pointer items-center justify-between gap-6">
<span>
<span className="block font-medium">Public roadmap</span>
<span className="text-sm text-gray-500">
Show planned work to customers.
</span>
</span>
<Switch
checked={publicRoadmap}
onChange={(event) => setPublicRoadmap(event.target.checked)}
name="publicRoadmap"
/>
</label>
)
}
Svelte
<script>
import Switch from '@/components/ui/switch/Switch.svelte'
let publicRoadmap = $state(true)
</script>
<label class="flex min-h-11 cursor-pointer items-center justify-between gap-6">
<span>
<span class="block font-medium">Public roadmap</span>
<span class="text-sm text-gray-500"> Show planned work to customers. </span>
</span>
<Switch bind:checked={publicRoadmap} name="publicRoadmap" />
</label>
The binding syntax is idiomatic to each framework. Every version produces the same native boolean control and keeps the visible setting row in application markup.
Switch or Checkbox?
Use Switch when changing one boolean takes effect immediately: publish a roadmap, enable a notification channel, show a widget, or turn a release flag on. The new state is the action and should be saved as soon as it changes.
Use Checkbox when the value belongs to a form that is submitted later, when zero or many items may be selected, or when a parent needs an indeterminate state. Use Radio for exactly one choice from a small visible set when it becomes available.
Switch is deliberately not a Checkbox variant. The two controls share a native checked value, but communicate different timing and intent.
Labels and state
Give every Switch a stable visible label. Do not change “Public roadmap” to “Hide public roadmap” when it turns on; the control already communicates on or off. A description may explain the consequence without becoming component configuration.
Wrapping the complete row is the tersest API and provides a generous touch target:
<label class="flex min-h-11 cursor-pointer items-center justify-between gap-6">
<span>
<span class="block font-medium">Webhook delivery</span>
<span id="webhook-help" class="text-sm text-gray-500">
Send deployment events to the configured endpoint.
</span>
</span>
<Switch
v-model="form.webhookEnabled"
name="webhookEnabled"
aria-describedby="webhook-help"
/>
</label>For a standalone control, target its id with a real <label>. Use aria-label only when no visible text can label it.
Saving and rollback
Switch updates the boolean immediately. The application decides whether that value belongs in a form, local preference, URL, or server record. For an optimistic server update:
- Keep the previous value.
- Show the next value immediately.
- Prevent competing requests while the save is pending.
- On failure, restore the previous value and explain what happened.
Try the failure path below. It intentionally restores the previous setting so the interface never claims a value the server rejected.
Toggle to test an honest failed save.
Do not use optimistic state for payments, irreversible actions, or settings the server commonly rejects. Keep the Switch disabled during a confirmed save and show success only after the server actually accepts the change.
Native behavior
- Tab focuses the switch and Space toggles it.
- Activating an associated label toggles it.
- A checked switch submits its
nameandvalue; an unchecked switch submits nothing. requiredparticipates in native constraint validation.disabledprevents interaction and submission.- Form reset restores the initial checked state.
- The native
checkedstate supplies switch accessibility state; do not add a separatearia-checkedvalue.
Klean does not replace these behaviors with custom keyboard handlers or a second state machine.
API
| Purpose | Vue | React | Svelte |
|---|---|---|---|
| Current value | boolean v-model | checked, onChange | bind:checked |
| Initial value | initial model | defaultChecked | initial state |
| Form | native input attributes | native input props | native attributes |
| Styling | class | className | class |
The component exposes its native element for explicit focus recovery and stable data-slot="switch", data-state, data-disabled, and data-invalid styling or test hooks. It has no collection values, mixed state, truthy-value mapping, variant, tone, size, label, description, saving, track, thumb, or part-class props.
Styling
The neutral default is monochrome. Caller Tailwind merges last, including standard checked:* and after:* utilities:
<!-- Compact operational setting -->
<Switch
class="h-5 w-9 after:size-4 checked:after:transform-[translate(1rem,-50%)]"
/>
<!-- Product colour without a component prop -->
<Switch
class="bg-stone-300 checked:bg-emerald-600 dark:checked:bg-emerald-400"
/>When changing track height or width, adjust the thumb size and checked translation in the same caller-owned recipe. Motion is brief by default and becomes quicker when the user prefers reduced motion.
Durable state
The primitive keeps native checked and reset state synchronized. Persistence remains an application decision:
- component state for a temporary preview;
- local storage for a personal browser preference;
- the URL only when another person should reproduce the setting from a link;
- the database for a product setting that must follow the account or team.
For network-backed settings, prevent request races and pair every optimistic change with rollback and visible error feedback.
Related components
- Radio — one mutually exclusive choice rather than an immediate boolean setting.
- Checkbox — submitted choices, collections, and indeterminate selection.
- Input — a value the user must enter rather than turn on or off.
- Button — a command rather than persistent boolean state.
- Toast — brief save confirmation or rollback feedback when inline context is not better.
- Dialog — confirm a consequential task; do not disguise a destructive command as a Switch.
Complete framework source
Vue
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, useAttrs } from 'vue'
import { twMerge } from 'tailwind-merge'
defineOptions({ inheritAttrs: false })
const model = defineModel({ type: Boolean, default: false })
const attrs = useAttrs()
const element = ref()
let form
const forwardedAttrs = computed(() => {
const {
class: _class,
type: _type,
role: _role,
checked: _checked,
'true-value': _trueValue,
'false-value': _falseValue,
'aria-checked': _ariaChecked,
'data-slot': _dataSlot,
'data-state': _dataState,
'data-disabled': _dataDisabled,
'data-invalid': _dataInvalid,
...rest
} = attrs
return rest
})
const disabled = computed(
() => attrs.disabled !== undefined && attrs.disabled !== false
)
const invalid = computed(
() => attrs['aria-invalid'] === true || attrs['aria-invalid'] === 'true'
)
function handleChange(event) {
model.value = Boolean(event.currentTarget.checked)
}
function handleReset() {
queueMicrotask(() => {
if (element.value) model.value = Boolean(element.value.checked)
})
}
onMounted(() => {
element.value.defaultChecked = Boolean(model.value)
form = element.value.form
form?.addEventListener('reset', handleReset)
})
onBeforeUnmount(() => {
form?.removeEventListener('reset', handleReset)
})
defineExpose({
element,
focus: (options) => element.value?.focus(options)
})
</script>
<template>
<input
ref="element"
v-bind="forwardedAttrs"
v-model="model"
type="checkbox"
role="switch"
data-slot="switch"
:data-state="model ? 'checked' : 'unchecked'"
:data-disabled="disabled ? '' : undefined"
:data-invalid="invalid ? '' : undefined"
:class="
twMerge(
[
`relative inline-flex h-6 w-11 shrink-0 cursor-pointer appearance-none rounded-full border-0 bg-gray-300 p-0 outline-none transition-colors duration-200 ease-[cubic-bezier(0.32,0.72,0,1)] after:pointer-events-none after:absolute after:left-0.5 after:top-1/2 after:block after:size-5 after:rounded-full after:bg-white after:transform-[translate(0,-50%)] after:[transition-property:transform] after:duration-200 after:ease-[cubic-bezier(0.32,0.72,0,1)] after:content-['']`,
'checked:bg-gray-950 checked:after:transform-[translate(1.25rem,-50%)]',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950',
'disabled:cursor-not-allowed disabled:opacity-50',
'aria-invalid:outline-2 aria-invalid:outline-offset-2 aria-invalid:outline-red-600 aria-invalid:focus-visible:outline-red-600',
'motion-reduce:duration-100 motion-reduce:ease-out motion-reduce:after:duration-100 motion-reduce:after:ease-out',
'dark:bg-gray-700 dark:checked:bg-white dark:checked:after:bg-gray-950 dark:focus-visible:outline-white dark:aria-invalid:outline-red-500',
'forced-colors:border forced-colors:border-[CanvasText] forced-colors:bg-[Canvas] forced-colors:checked:bg-[Highlight] forced-colors:after:bg-[CanvasText] forced-colors:checked:after:bg-[HighlightText]'
],
attrs.class
)
"
@change="handleChange"
/>
</template>
React
import { forwardRef, useCallback, useEffect, useRef, useState } from 'react'
import { twMerge } from 'tailwind-merge'
const BASE_CLASSES = [
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer appearance-none rounded-full border-0 bg-gray-300 p-0 outline-none transition-colors duration-200 ease-[cubic-bezier(0.32,0.72,0,1)] after:pointer-events-none after:absolute after:left-0.5 after:top-1/2 after:block after:size-5 after:rounded-full after:bg-white after:transform-[translate(0,-50%)] after:[transition-property:transform] after:duration-200 after:ease-[cubic-bezier(0.32,0.72,0,1)] after:content-['']",
'checked:bg-gray-950 checked:after:transform-[translate(1.25rem,-50%)]',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950',
'disabled:cursor-not-allowed disabled:opacity-50',
'aria-invalid:outline-2 aria-invalid:outline-offset-2 aria-invalid:outline-red-600 aria-invalid:focus-visible:outline-red-600',
'motion-reduce:duration-100 motion-reduce:ease-out motion-reduce:after:duration-100 motion-reduce:after:ease-out',
'dark:bg-gray-700 dark:checked:bg-white dark:checked:after:bg-gray-950 dark:focus-visible:outline-white dark:aria-invalid:outline-red-500',
'forced-colors:border forced-colors:border-[CanvasText] forced-colors:bg-[Canvas] forced-colors:checked:bg-[Highlight] forced-colors:after:bg-[CanvasText] forced-colors:checked:after:bg-[HighlightText]'
]
const Switch = forwardRef(function Switch(
{
checked,
defaultChecked = false,
disabled = false,
'aria-invalid': ariaInvalid,
'aria-checked': _ariaChecked,
className,
onChange,
role: _role,
type: _type,
'data-slot': _dataSlot,
'data-state': _dataState,
'data-disabled': _dataDisabled,
'data-invalid': _dataInvalid,
...props
},
forwardedRef
) {
const localRef = useRef(null)
const controlled = checked !== undefined
const [localChecked, setLocalChecked] = useState(Boolean(defaultChecked))
const resolvedChecked = controlled ? Boolean(checked) : localChecked
const invalid = ariaInvalid === true || ariaInvalid === 'true'
useEffect(() => {
const node = localRef.current
const form = node?.form
if (!form || controlled) return
function handleReset() {
queueMicrotask(() => setLocalChecked(Boolean(node.checked)))
}
form.addEventListener('reset', handleReset)
return () => form.removeEventListener('reset', handleReset)
}, [controlled])
const setElement = useCallback(
(node) => {
localRef.current = node
if (typeof forwardedRef === 'function') forwardedRef(node)
else if (forwardedRef) forwardedRef.current = node
},
[forwardedRef]
)
function handleChange(event) {
if (!controlled) setLocalChecked(Boolean(event.target.checked))
onChange?.(event)
}
return (
<input
{...props}
ref={setElement}
type="checkbox"
role="switch"
checked={controlled ? Boolean(checked) : undefined}
defaultChecked={controlled ? undefined : Boolean(defaultChecked)}
disabled={disabled}
aria-invalid={ariaInvalid}
data-slot="switch"
data-state={resolvedChecked ? 'checked' : 'unchecked'}
data-disabled={disabled ? '' : undefined}
data-invalid={invalid ? '' : undefined}
className={twMerge(BASE_CLASSES, className)}
onChange={handleChange}
/>
)
})
export default Switch
Svelte
<script>
import { twMerge } from "tailwind-merge";
const BASE_CLASSES = [
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer appearance-none rounded-full border-0 bg-gray-300 p-0 outline-none transition-colors duration-200 ease-[cubic-bezier(0.32,0.72,0,1)] after:pointer-events-none after:absolute after:left-0.5 after:top-1/2 after:block after:size-5 after:rounded-full after:bg-white after:transform-[translate(0,-50%)] after:[transition-property:transform] after:duration-200 after:ease-[cubic-bezier(0.32,0.72,0,1)] after:content-['']",
"checked:bg-gray-950 checked:after:transform-[translate(1.25rem,-50%)]",
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950",
"disabled:cursor-not-allowed disabled:opacity-50",
"aria-invalid:outline-2 aria-invalid:outline-offset-2 aria-invalid:outline-red-600 aria-invalid:focus-visible:outline-red-600",
"motion-reduce:duration-100 motion-reduce:ease-out motion-reduce:after:duration-100 motion-reduce:after:ease-out",
"dark:bg-gray-700 dark:checked:bg-white dark:checked:after:bg-gray-950 dark:focus-visible:outline-white dark:aria-invalid:outline-red-500",
"forced-colors:border forced-colors:border-[CanvasText] forced-colors:bg-[Canvas] forced-colors:checked:bg-[Highlight] forced-colors:after:bg-[CanvasText] forced-colors:checked:after:bg-[HighlightText]",
];
let {
checked = $bindable(false),
disabled = false,
"aria-invalid": ariaInvalid,
"aria-checked": _ariaChecked,
class: className,
role: _role,
type: _type,
"data-slot": _dataSlot,
"data-state": _dataState,
"data-disabled": _dataDisabled,
"data-invalid": _dataInvalid,
...props
} = $props();
let element = $state();
const initialChecked = Boolean(checked);
let state = $derived(Boolean(checked) ? "checked" : "unchecked");
let invalid = $derived(ariaInvalid === true || ariaInvalid === "true");
$effect(() => {
const node = element;
if (!node) return;
node.defaultChecked = initialChecked;
const form = node.form;
if (!form) return;
function handleReset() {
queueMicrotask(() => {
checked = Boolean(node.checked);
});
}
form.addEventListener("reset", handleReset);
return () => form.removeEventListener("reset", handleReset);
});
export function getElement() {
return element;
}
export function focus(options) {
element?.focus(options);
}
</script>
<input
{...props}
bind:this={element}
bind:checked
type="checkbox"
role="switch"
{disabled}
aria-invalid={ariaInvalid}
data-slot="switch"
data-state={state}
data-disabled={disabled ? "" : undefined}
data-invalid={invalid ? "" : undefined}
class={twMerge(BASE_CLASSES, className)}
/>