Actions
Toggle
Botón de UI para estados booleanos, sin formulario detrás
Preview
size
label
disabled
Code
import css from "./Toggle.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 ToggleProps {
checked: boolean;
size: Size | ResponsiveSize;
label?: string;
"aria-label"?: string;
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 Toggle({
checked,
size,
label,
"aria-label": ariaLabel,
disabled,
onChange,
}: ToggleProps) {
const [interacted, setInteracted] = useState(false);
const toggle = () => {
if (disabled) return;
setInteracted(true);
onChange(!checked);
};
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={label ? undefined : ariaLabel}
disabled={disabled}
className={`${css.toggle} ${getSizeClasses(size)} ${checked ? css.on : ""} ${disabled ? css.disabled : ""}`}
onClick={toggle}
>
<span className={css.toggle_track}>
<span
className={`${css.track_dot} ${interacted ? css.animated : ""}`}
></span>
</span>
{label && <span className={css.toggle_label}>{label}</span>}
</button>
);
}
.toggle {
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);
padding: 0;
border: 0;
background: none;
cursor: pointer;
user-select: none;
/* Same track as Switch; the dot keeps 3px of air to Switch's 6. */
&.xs {
--toggle-width: var(--size-32);
--toggle-height: 18px;
--toggle-dot: 12px;
font-size: var(--text-14);
}
&.sm {
--toggle-width: var(--size-36);
--toggle-height: 20px;
--toggle-dot: 14px;
font-size: var(--text-14);
}
&.md {
--toggle-width: var(--size-40);
--toggle-height: 22px;
--toggle-dot: 16px;
font-size: var(--text-16);
}
&.lg {
--toggle-width: var(--size-44);
--toggle-height: 24px;
--toggle-dot: 18px;
font-size: var(--text-16);
}
&.xs_base {
--toggle-width: var(--size-32);
--toggle-height: 18px;
--toggle-dot: 12px;
font-size: var(--text-14);
}
&.sm_base {
--toggle-width: var(--size-36);
--toggle-height: 20px;
--toggle-dot: 14px;
font-size: var(--text-14);
}
&.md_base {
--toggle-width: var(--size-40);
--toggle-height: 22px;
--toggle-dot: 16px;
font-size: var(--text-16);
}
&.lg_base {
--toggle-width: var(--size-44);
--toggle-height: 24px;
--toggle-dot: 18px;
font-size: var(--text-16);
}
@media (min-width: 480px) {
&.xs_mobile {
--toggle-width: var(--size-32);
--toggle-height: 18px;
--toggle-dot: 12px;
font-size: var(--text-14);
}
&.sm_mobile {
--toggle-width: var(--size-36);
--toggle-height: 20px;
--toggle-dot: 14px;
font-size: var(--text-14);
}
&.md_mobile {
--toggle-width: var(--size-40);
--toggle-height: 22px;
--toggle-dot: 16px;
font-size: var(--text-16);
}
&.lg_mobile {
--toggle-width: var(--size-44);
--toggle-height: 24px;
--toggle-dot: 18px;
font-size: var(--text-16);
}
}
@media (min-width: 768px) {
&.xs_tablet {
--toggle-width: var(--size-32);
--toggle-height: 18px;
--toggle-dot: 12px;
font-size: var(--text-14);
}
&.sm_tablet {
--toggle-width: var(--size-36);
--toggle-height: 20px;
--toggle-dot: 14px;
font-size: var(--text-14);
}
&.md_tablet {
--toggle-width: var(--size-40);
--toggle-height: 22px;
--toggle-dot: 16px;
font-size: var(--text-16);
}
&.lg_tablet {
--toggle-width: var(--size-44);
--toggle-height: 24px;
--toggle-dot: 18px;
font-size: var(--text-16);
}
}
@media (min-width: 1024px) {
&.xs_desktop {
--toggle-width: var(--size-32);
--toggle-height: 18px;
--toggle-dot: 12px;
font-size: var(--text-14);
}
&.sm_desktop {
--toggle-width: var(--size-36);
--toggle-height: 20px;
--toggle-dot: 14px;
font-size: var(--text-14);
}
&.md_desktop {
--toggle-width: var(--size-40);
--toggle-height: 22px;
--toggle-dot: 16px;
font-size: var(--text-16);
}
&.lg_desktop {
--toggle-width: var(--size-44);
--toggle-height: 24px;
--toggle-dot: 18px;
font-size: var(--text-16);
}
}
@media (min-width: 1440px) {
&.xs_wide {
--toggle-width: var(--size-32);
--toggle-height: 18px;
--toggle-dot: 12px;
font-size: var(--text-14);
}
&.sm_wide {
--toggle-width: var(--size-36);
--toggle-height: 20px;
--toggle-dot: 14px;
font-size: var(--text-14);
}
&.md_wide {
--toggle-width: var(--size-40);
--toggle-height: 22px;
--toggle-dot: 16px;
font-size: var(--text-16);
}
&.lg_wide {
--toggle-width: var(--size-44);
--toggle-height: 24px;
--toggle-dot: 18px;
font-size: var(--text-16);
}
}
.toggle_track {
position: relative;
width: var(--toggle-width);
height: var(--toggle-height);
border-radius: var(--radius-pill);
background: color-mix(in srgb, var(--color-text-tertiary) 40%, transparent);
transition: background 0.2s ease;
}
.track_dot {
position: absolute;
top: 50%;
left: 3px;
width: var(--toggle-dot);
height: var(--toggle-dot);
border-radius: var(--radius-full);
background: var(--color-bg);
transform: translateY(-50%);
transition: left 0.2s ease;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
&.animated {
animation: stretch-off 0.2s ease;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
}
}
&.on {
.toggle_track {
background: var(--color-accent);
}
.track_dot {
left: calc(100% - var(--toggle-dot) - 3px);
&.animated {
animation: stretch-on 0.2s ease;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
}
}
}
.toggle_label {
font-weight: var(--weight-500);
}
/* Bare, the button hugs the track and two rings would overlap, so the track alone takes it. */
&:has(.toggle_label):focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
&:not(:has(.toggle_label)):focus-visible {
outline: none;
.toggle_track {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
}
&.disabled {
opacity: 0.5;
pointer-events: none;
}
}
@keyframes stretch-on {
50% {
transform: translateY(-50%) scaleX(1.5);
}
}
@keyframes stretch-off {
50% {
transform: translateY(-50%) 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 ToggleProps {
size: Size | ResponsiveSize;
label?: string;
ariaLabel?: string;
disabled?: boolean;
}
const { size, ariaLabel, disabled } = defineProps<ToggleProps>();
const model = defineModel<boolean>({ required: true });
// Gates the stretch animation so a toggle 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(" ");
});
const toggle = () => {
if (disabled) return;
interacted.value = true;
model.value = !model.value;
};
</script>
<template>
<button
type="button"
role="switch"
:aria-checked="model"
:aria-label="label ? undefined : ariaLabel"
:disabled="disabled"
class="toggle"
:class="[sizeClasses, { on: model, disabled }]"
@click="toggle"
>
<span class="toggle_track">
<span class="track_dot" :class="{ animated: interacted }"></span>
</span>
<span v-if="label" class="toggle_label">{{ label }}</span>
</button>
</template>
<style scoped>
.toggle {
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);
padding: 0;
border: 0;
background: none;
cursor: pointer;
user-select: none;
/* Same track as Switch; the dot keeps 3px of air to Switch's 6. */
&.xs {
--toggle-width: var(--size-32);
--toggle-height: 18px;
--toggle-dot: 12px;
font-size: var(--text-14);
}
&.sm {
--toggle-width: var(--size-36);
--toggle-height: 20px;
--toggle-dot: 14px;
font-size: var(--text-14);
}
&.md {
--toggle-width: var(--size-40);
--toggle-height: 22px;
--toggle-dot: 16px;
font-size: var(--text-16);
}
&.lg {
--toggle-width: var(--size-44);
--toggle-height: 24px;
--toggle-dot: 18px;
font-size: var(--text-16);
}
&.xs_base {
--toggle-width: var(--size-32);
--toggle-height: 18px;
--toggle-dot: 12px;
font-size: var(--text-14);
}
&.sm_base {
--toggle-width: var(--size-36);
--toggle-height: 20px;
--toggle-dot: 14px;
font-size: var(--text-14);
}
&.md_base {
--toggle-width: var(--size-40);
--toggle-height: 22px;
--toggle-dot: 16px;
font-size: var(--text-16);
}
&.lg_base {
--toggle-width: var(--size-44);
--toggle-height: 24px;
--toggle-dot: 18px;
font-size: var(--text-16);
}
@media (min-width: 480px) {
&.xs_mobile {
--toggle-width: var(--size-32);
--toggle-height: 18px;
--toggle-dot: 12px;
font-size: var(--text-14);
}
&.sm_mobile {
--toggle-width: var(--size-36);
--toggle-height: 20px;
--toggle-dot: 14px;
font-size: var(--text-14);
}
&.md_mobile {
--toggle-width: var(--size-40);
--toggle-height: 22px;
--toggle-dot: 16px;
font-size: var(--text-16);
}
&.lg_mobile {
--toggle-width: var(--size-44);
--toggle-height: 24px;
--toggle-dot: 18px;
font-size: var(--text-16);
}
}
@media (min-width: 768px) {
&.xs_tablet {
--toggle-width: var(--size-32);
--toggle-height: 18px;
--toggle-dot: 12px;
font-size: var(--text-14);
}
&.sm_tablet {
--toggle-width: var(--size-36);
--toggle-height: 20px;
--toggle-dot: 14px;
font-size: var(--text-14);
}
&.md_tablet {
--toggle-width: var(--size-40);
--toggle-height: 22px;
--toggle-dot: 16px;
font-size: var(--text-16);
}
&.lg_tablet {
--toggle-width: var(--size-44);
--toggle-height: 24px;
--toggle-dot: 18px;
font-size: var(--text-16);
}
}
@media (min-width: 1024px) {
&.xs_desktop {
--toggle-width: var(--size-32);
--toggle-height: 18px;
--toggle-dot: 12px;
font-size: var(--text-14);
}
&.sm_desktop {
--toggle-width: var(--size-36);
--toggle-height: 20px;
--toggle-dot: 14px;
font-size: var(--text-14);
}
&.md_desktop {
--toggle-width: var(--size-40);
--toggle-height: 22px;
--toggle-dot: 16px;
font-size: var(--text-16);
}
&.lg_desktop {
--toggle-width: var(--size-44);
--toggle-height: 24px;
--toggle-dot: 18px;
font-size: var(--text-16);
}
}
@media (min-width: 1440px) {
&.xs_wide {
--toggle-width: var(--size-32);
--toggle-height: 18px;
--toggle-dot: 12px;
font-size: var(--text-14);
}
&.sm_wide {
--toggle-width: var(--size-36);
--toggle-height: 20px;
--toggle-dot: 14px;
font-size: var(--text-14);
}
&.md_wide {
--toggle-width: var(--size-40);
--toggle-height: 22px;
--toggle-dot: 16px;
font-size: var(--text-16);
}
&.lg_wide {
--toggle-width: var(--size-44);
--toggle-height: 24px;
--toggle-dot: 18px;
font-size: var(--text-16);
}
}
.toggle_track {
position: relative;
width: var(--toggle-width);
height: var(--toggle-height);
border-radius: var(--radius-pill);
background: color-mix(in srgb, var(--color-text-tertiary) 40%, transparent);
transition: background 0.2s ease;
}
.track_dot {
position: absolute;
top: 50%;
left: 3px;
width: var(--toggle-dot);
height: var(--toggle-dot);
border-radius: var(--radius-full);
background: var(--color-bg);
transform: translateY(-50%);
transition: left 0.2s ease;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
&.animated {
animation: stretch-off 0.2s ease;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
}
}
&.on {
.toggle_track {
background: var(--color-accent);
}
.track_dot {
left: calc(100% - var(--toggle-dot) - 3px);
&.animated {
animation: stretch-on 0.2s ease;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
}
}
}
.toggle_label {
font-weight: var(--weight-500);
}
/* Bare, the button hugs the track and two rings would overlap, so the track alone takes it. */
&:has(.toggle_label):focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
&:not(:has(.toggle_label)):focus-visible {
outline: none;
.toggle_track {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
}
&.disabled {
opacity: 0.5;
pointer-events: none;
}
}
@keyframes stretch-on {
50% {
transform: translateY(-50%) scaleX(1.5);
}
}
@keyframes stretch-off {
50% {
transform: translateY(-50%) 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. |
| 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. |
| 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. |