Skip to content

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.

Switch.vue
The whole setting row is the label and the native checked state is the visible state.

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.

Terminal
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

PublicRoadmapSetting.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

PublicRoadmapSetting.jsx
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

PublicRoadmapSetting.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:

vue
<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:

  1. Keep the previous value.
  2. Show the next value immediately.
  3. Prevent competing requests while the save is pending.
  4. 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 name and value; an unchecked switch submits nothing.
  • required participates in native constraint validation.
  • disabled prevents interaction and submission.
  • Form reset restores the initial checked state.
  • The native checked state supplies switch accessibility state; do not add a separate aria-checked value.

Klean does not replace these behaviors with custom keyboard handlers or a second state machine.

API

PurposeVueReactSvelte
Current valueboolean v-modelchecked, onChangebind:checked
Initial valueinitial modeldefaultCheckedinitial state
Formnative input attributesnative input propsnative attributes
StylingclassclassNameclass

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:

vue
<!-- 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.

  • 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

Switch.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

Switch.jsx
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

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)}
/>

All open source projects are released under the MIT License.