Forms
Switch
Interruptor de formulario para estados booleanos
Preview
size
label
required
error
disabled
Code
import css from "./Switch.module.css";
import { useState } from "react";
type Size = "xs" | "sm" | "md" | "lg";
interface ResponsiveSize {
base: Size;
mobile?: Size;
tablet?: Size;
desktop?: Size;
wide?: Size;
}
interface SwitchProps {
size: Size | ResponsiveSize;
name?: string;
label?: string;
"aria-label"?: string;
checked: boolean;
required?: boolean;
error?: boolean;
disabled?: boolean;
onChange: (checked: boolean) => void;
}
function getSizeClasses(size: Size | ResponsiveSize): string {
if (typeof size === "string") {
return css[size];
}
const classes = [css[`${size.base}_base`]];
if (size.mobile) classes.push(css[`${size.mobile}_mobile`]);
if (size.tablet) classes.push(css[`${size.tablet}_tablet`]);
if (size.desktop) classes.push(css[`${size.desktop}_desktop`]);
if (size.wide) classes.push(css[`${size.wide}_wide`]);
return classes.join(" ");
}
export default function Switch({
size,
name,
label,
"aria-label": ariaLabel,
checked,
required,
error,
disabled,
onChange,
}: SwitchProps) {
const [interacted, setInteracted] = useState(false);
const handleChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
setInteracted(true);
onChange(ev.target.checked);
};
const toggle = () => {
if (disabled) return;
setInteracted(true);
onChange(!checked);
};
const onKeyDown = (ev: React.KeyboardEvent) => {
if (ev.key === "Enter") {
ev.preventDefault();
toggle();
}
};
const Wrapper = label ? "label" : "span";
return (
<Wrapper
className={`
${css.switch}
${getSizeClasses(size)}
${error ? css.error : ""}
${disabled ? css.disabled : ""}
`}
>
<input
type="checkbox"
role="switch"
name={name}
checked={checked}
required={required}
disabled={disabled}
aria-label={label ? undefined : ariaLabel}
aria-invalid={error}
onChange={handleChange}
onKeyDown={onKeyDown}
/>
<span className={`${css.switch_box} ${checked ? css.active : ""}`}>
<span
className={`${css.box_dot} ${interacted ? css.animated : ""}`}
></span>
</span>
{label && (
<span className={css.switch_label}>
{label}
{required && <span aria-hidden="true">*</span>}
</span>
)}
</Wrapper>
);
}
.switch {
position: relative;
/* Keeps the stretched input's layer inside: loose, it covers a popover opened over it. */
isolation: isolate;
display: inline-flex;
align-items: center;
gap: var(--space-12);
/* WCAG 2.5.8: the root never measures under 24px, whatever box the step draws. */
min-height: 24px;
width: fit-content;
/* Only the keyboard ring reads it: the box itself paints nothing. */
border-radius: var(--radius-4);
cursor: pointer;
user-select: none;
/* The track rides the control ladder; its height is half the field ladder,
which has no half steps. The dot keeps 6px of air, Toggle's 3. */
&.xs {
--switch-width: var(--size-32);
--switch-height: 18px;
--switch-dot: 6px;
font-size: var(--text-14);
}
&.sm {
--switch-width: var(--size-36);
--switch-height: 20px;
--switch-dot: 8px;
font-size: var(--text-14);
}
&.md {
--switch-width: var(--size-40);
--switch-height: 22px;
--switch-dot: 10px;
font-size: var(--text-16);
}
&.lg {
--switch-width: var(--size-44);
--switch-height: 24px;
--switch-dot: 12px;
font-size: var(--text-16);
}
&.xs_base {
--switch-width: var(--size-32);
--switch-height: 18px;
--switch-dot: 6px;
font-size: var(--text-14);
}
&.sm_base {
--switch-width: var(--size-36);
--switch-height: 20px;
--switch-dot: 8px;
font-size: var(--text-14);
}
&.md_base {
--switch-width: var(--size-40);
--switch-height: 22px;
--switch-dot: 10px;
font-size: var(--text-16);
}
&.lg_base {
--switch-width: var(--size-44);
--switch-height: 24px;
--switch-dot: 12px;
font-size: var(--text-16);
}
@media (min-width: 480px) {
&.xs_mobile {
--switch-width: var(--size-32);
--switch-height: 18px;
--switch-dot: 6px;
font-size: var(--text-14);
}
&.sm_mobile {
--switch-width: var(--size-36);
--switch-height: 20px;
--switch-dot: 8px;
font-size: var(--text-14);
}
&.md_mobile {
--switch-width: var(--size-40);
--switch-height: 22px;
--switch-dot: 10px;
font-size: var(--text-16);
}
&.lg_mobile {
--switch-width: var(--size-44);
--switch-height: 24px;
--switch-dot: 12px;
font-size: var(--text-16);
}
}
@media (min-width: 768px) {
&.xs_tablet {
--switch-width: var(--size-32);
--switch-height: 18px;
--switch-dot: 6px;
font-size: var(--text-14);
}
&.sm_tablet {
--switch-width: var(--size-36);
--switch-height: 20px;
--switch-dot: 8px;
font-size: var(--text-14);
}
&.md_tablet {
--switch-width: var(--size-40);
--switch-height: 22px;
--switch-dot: 10px;
font-size: var(--text-16);
}
&.lg_tablet {
--switch-width: var(--size-44);
--switch-height: 24px;
--switch-dot: 12px;
font-size: var(--text-16);
}
}
@media (min-width: 1024px) {
&.xs_desktop {
--switch-width: var(--size-32);
--switch-height: 18px;
--switch-dot: 6px;
font-size: var(--text-14);
}
&.sm_desktop {
--switch-width: var(--size-36);
--switch-height: 20px;
--switch-dot: 8px;
font-size: var(--text-14);
}
&.md_desktop {
--switch-width: var(--size-40);
--switch-height: 22px;
--switch-dot: 10px;
font-size: var(--text-16);
}
&.lg_desktop {
--switch-width: var(--size-44);
--switch-height: 24px;
--switch-dot: 12px;
font-size: var(--text-16);
}
}
@media (min-width: 1440px) {
&.xs_wide {
--switch-width: var(--size-32);
--switch-height: 18px;
--switch-dot: 6px;
font-size: var(--text-14);
}
&.sm_wide {
--switch-width: var(--size-36);
--switch-height: 20px;
--switch-dot: 8px;
font-size: var(--text-14);
}
&.md_wide {
--switch-width: var(--size-40);
--switch-height: 22px;
--switch-dot: 10px;
font-size: var(--text-16);
}
&.lg_wide {
--switch-width: var(--size-44);
--switch-height: 24px;
--switch-dot: 12px;
font-size: var(--text-16);
}
}
/* Transparent and stretched over the root: every click lands on the native control. */
input {
position: absolute;
inset: 0;
z-index: 1;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: pointer;
}
.switch_box {
position: relative;
display: flex;
align-items: center;
width: var(--switch-width);
height: var(--switch-height);
border: var(--border-surface) solid var(--color-text);
border-radius: var(--radius-pill);
transition: background 0.3s ease, border-color 0.3s ease;
.box_dot {
position: absolute;
left: 5px;
width: var(--switch-dot);
height: var(--switch-dot);
border-radius: var(--radius-full);
background: var(--color-text);
transition:
left 0.3s ease,
background 0.3s ease;
@media (prefers-reduced-motion: reduce) {
transition: background 0.3s ease;
}
&.animated {
animation: stretch-off 0.3s ease;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
}
}
&.active {
background: var(--color-accent);
border-color: var(--color-accent);
.box_dot {
left: calc(100% - var(--switch-dot) - 5px);
background: var(--color-bg);
&.animated {
animation: stretch-on 0.3s ease;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
}
}
}
}
.switch_label {
font-family: var(--font-body);
font-weight: var(--weight-500);
color: var(--color-text);
transition: color 0.3s ease;
}
&:has(.switch_label):has(input:focus-visible) {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
&:not(:has(.switch_label)):has(input:focus-visible) .switch_box {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
&.disabled {
opacity: 0.5;
pointer-events: none;
}
&.error {
.switch_box {
border-color: var(--color-error);
.box_dot {
background: var(--color-error);
}
&.active {
background: var(--color-error);
.box_dot {
background: var(--color-bg);
}
}
}
.switch_label {
color: var(--color-error);
}
}
}
@keyframes stretch-on {
50% {
transform: scaleX(1.5);
}
}
@keyframes stretch-off {
50% {
transform: scaleX(1.5);
}
}
<script setup lang="ts">
import { computed, ref } from "vue";
type Size = "xs" | "sm" | "md" | "lg";
interface ResponsiveSize {
base: Size;
mobile?: Size;
tablet?: Size;
desktop?: Size;
wide?: Size;
}
interface SwitchProps {
size: Size | ResponsiveSize;
name?: string;
label?: string;
ariaLabel?: string;
required?: boolean;
error?: boolean;
disabled?: boolean;
}
const { size, disabled } = defineProps<SwitchProps>();
const model = defineModel<boolean>({ required: true });
// Gates the stretch animation so a switch that mounts already on doesn't play it.
const interacted = ref(false);
const sizeClasses = computed(() => {
if (typeof size === "string") {
return size;
}
const classes = [`${size.base}_base`];
if (size.mobile) classes.push(`${size.mobile}_mobile`);
if (size.tablet) classes.push(`${size.tablet}_tablet`);
if (size.desktop) classes.push(`${size.desktop}_desktop`);
if (size.wide) classes.push(`${size.wide}_wide`);
return classes.join(" ");
});
// A watcher on the model would also fire on parent-driven changes, which must not animate.
const markInteracted = () => {
interacted.value = true;
};
const toggle = () => {
if (disabled) return;
markInteracted();
model.value = !model.value;
};
</script>
<template>
<component
:is="label ? 'label' : 'span'"
class="switch"
:class="[sizeClasses, { error, disabled }]"
>
<input
v-model="model"
type="checkbox"
role="switch"
:name="name"
:required="required"
:disabled="disabled"
:aria-label="label ? undefined : ariaLabel"
:aria-invalid="error"
@change="markInteracted"
@keydown.enter.prevent="toggle"
/>
<span class="switch_box" :class="{ active: model }">
<span class="box_dot" :class="{ animated: interacted }"></span>
</span>
<span v-if="label" class="switch_label">
{{ label }}<span v-if="required" aria-hidden="true">*</span>
</span>
</component>
</template>
<style scoped>
.switch {
position: relative;
/* Keeps the stretched input's layer inside: loose, it covers a popover opened over it. */
isolation: isolate;
display: inline-flex;
align-items: center;
gap: var(--space-12);
/* WCAG 2.5.8: the root never measures under 24px, whatever box the step draws. */
min-height: 24px;
width: fit-content;
/* Only the keyboard ring reads it: the box itself paints nothing. */
border-radius: var(--radius-4);
cursor: pointer;
user-select: none;
/* The track rides the control ladder; its height is half the field ladder,
which has no half steps. The dot keeps 6px of air, Toggle's 3. */
&.xs {
--switch-width: var(--size-32);
--switch-height: 18px;
--switch-dot: 6px;
font-size: var(--text-14);
}
&.sm {
--switch-width: var(--size-36);
--switch-height: 20px;
--switch-dot: 8px;
font-size: var(--text-14);
}
&.md {
--switch-width: var(--size-40);
--switch-height: 22px;
--switch-dot: 10px;
font-size: var(--text-16);
}
&.lg {
--switch-width: var(--size-44);
--switch-height: 24px;
--switch-dot: 12px;
font-size: var(--text-16);
}
&.xs_base {
--switch-width: var(--size-32);
--switch-height: 18px;
--switch-dot: 6px;
font-size: var(--text-14);
}
&.sm_base {
--switch-width: var(--size-36);
--switch-height: 20px;
--switch-dot: 8px;
font-size: var(--text-14);
}
&.md_base {
--switch-width: var(--size-40);
--switch-height: 22px;
--switch-dot: 10px;
font-size: var(--text-16);
}
&.lg_base {
--switch-width: var(--size-44);
--switch-height: 24px;
--switch-dot: 12px;
font-size: var(--text-16);
}
@media (min-width: 480px) {
&.xs_mobile {
--switch-width: var(--size-32);
--switch-height: 18px;
--switch-dot: 6px;
font-size: var(--text-14);
}
&.sm_mobile {
--switch-width: var(--size-36);
--switch-height: 20px;
--switch-dot: 8px;
font-size: var(--text-14);
}
&.md_mobile {
--switch-width: var(--size-40);
--switch-height: 22px;
--switch-dot: 10px;
font-size: var(--text-16);
}
&.lg_mobile {
--switch-width: var(--size-44);
--switch-height: 24px;
--switch-dot: 12px;
font-size: var(--text-16);
}
}
@media (min-width: 768px) {
&.xs_tablet {
--switch-width: var(--size-32);
--switch-height: 18px;
--switch-dot: 6px;
font-size: var(--text-14);
}
&.sm_tablet {
--switch-width: var(--size-36);
--switch-height: 20px;
--switch-dot: 8px;
font-size: var(--text-14);
}
&.md_tablet {
--switch-width: var(--size-40);
--switch-height: 22px;
--switch-dot: 10px;
font-size: var(--text-16);
}
&.lg_tablet {
--switch-width: var(--size-44);
--switch-height: 24px;
--switch-dot: 12px;
font-size: var(--text-16);
}
}
@media (min-width: 1024px) {
&.xs_desktop {
--switch-width: var(--size-32);
--switch-height: 18px;
--switch-dot: 6px;
font-size: var(--text-14);
}
&.sm_desktop {
--switch-width: var(--size-36);
--switch-height: 20px;
--switch-dot: 8px;
font-size: var(--text-14);
}
&.md_desktop {
--switch-width: var(--size-40);
--switch-height: 22px;
--switch-dot: 10px;
font-size: var(--text-16);
}
&.lg_desktop {
--switch-width: var(--size-44);
--switch-height: 24px;
--switch-dot: 12px;
font-size: var(--text-16);
}
}
@media (min-width: 1440px) {
&.xs_wide {
--switch-width: var(--size-32);
--switch-height: 18px;
--switch-dot: 6px;
font-size: var(--text-14);
}
&.sm_wide {
--switch-width: var(--size-36);
--switch-height: 20px;
--switch-dot: 8px;
font-size: var(--text-14);
}
&.md_wide {
--switch-width: var(--size-40);
--switch-height: 22px;
--switch-dot: 10px;
font-size: var(--text-16);
}
&.lg_wide {
--switch-width: var(--size-44);
--switch-height: 24px;
--switch-dot: 12px;
font-size: var(--text-16);
}
}
/* Transparent and stretched over the root: every click lands on the native control. */
input {
position: absolute;
inset: 0;
z-index: 1;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: pointer;
}
.switch_box {
position: relative;
display: flex;
align-items: center;
width: var(--switch-width);
height: var(--switch-height);
border: var(--border-surface) solid var(--color-text);
border-radius: var(--radius-pill);
transition: background 0.3s ease, border-color 0.3s ease;
.box_dot {
position: absolute;
left: 5px;
width: var(--switch-dot);
height: var(--switch-dot);
border-radius: var(--radius-full);
background: var(--color-text);
transition:
left 0.3s ease,
background 0.3s ease;
@media (prefers-reduced-motion: reduce) {
transition: background 0.3s ease;
}
&.animated {
animation: stretch-off 0.3s ease;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
}
}
&.active {
background: var(--color-accent);
border-color: var(--color-accent);
.box_dot {
left: calc(100% - var(--switch-dot) - 5px);
background: var(--color-bg);
&.animated {
animation: stretch-on 0.3s ease;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
}
}
}
}
.switch_label {
font-family: var(--font-body);
font-weight: var(--weight-500);
color: var(--color-text);
transition: color 0.3s ease;
}
&:has(.switch_label):has(input:focus-visible) {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
&:not(:has(.switch_label)):has(input:focus-visible) .switch_box {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
&.disabled {
opacity: 0.5;
pointer-events: none;
}
&.error {
.switch_box {
border-color: var(--color-error);
.box_dot {
background: var(--color-error);
}
&.active {
background: var(--color-error);
.box_dot {
background: var(--color-bg);
}
}
}
.switch_label {
color: var(--color-error);
}
}
}
@keyframes stretch-on {
50% {
transform: scaleX(1.5);
}
}
@keyframes stretch-off {
50% {
transform: scaleX(1.5);
}
}
</style>
Props
| Prop | Type | Required | Description |
|---|---|---|---|
| size | "xs" | "sm" | "md" | "lg" | ResponsiveSize | required | Tamaño del interruptor (track, dot y label). Fijo o responsive. |
| name | string | — | Nombre del campo cuando vive en un formulario; sin él no se envía. |
| label | string | — | Texto junto al interruptor. Sin label, queda solo el track. |
| aria-label | string | — | Nombre del control para lectores de pantalla. Requerida si no hay label; con label se ignora. |
| required | boolean | — | Marca el campo como obligatorio (añade * al label y el required nativo). |
| error | boolean | — | Estado de error visual (borde rojo, aria-invalid). |
| disabled | boolean | — | Desactiva el interruptor (no interactivo, opacidad reducida). |
| checked | boolean | required | Estado activado (controlado). |
| onChange | (checked: boolean) => void | required | Callback con el nuevo estado en cada cambio. |