Toggle
A switch that slides between on and off without a form submit. Instant preferences and feature flags.
Editorial
<!-- F12c editorial — non-derivable only. Review: Karen. -->
## Ejemplos
Instant preference:
```tsx import { Toggle } from '@/components/atoms/Toggle';
<Toggle label="Show activity status" checked={showStatus} onChange={setShowStatus} /> ```
## Accesibilidad
- Prefer the built-in `label` (or `aria-label`) — a naked switch has no accessible name. - `onChange` fires for immediate prefs; do not rely on a separate submit for the only state change.
### Correcto
- role='switch' — screen readers anuncian 'toggle' o 'switch', no 'checkbox' - Input nativo `<input type='checkbox'>` maneja el estado internamente - Touch target 44x40px cumple WCAG - Label clickeable — click en el texto togglea - focus-visible ring combinado con inset shadow (no los reemplaza) - Space togglea on/off (comportamiento nativo)
### Evitar
- No usar Toggle para seleccion multiple — usar Checkbox - No usar Toggle sin label a menos que el contexto sea obvio (ej: dentro de un settings row) - No simular toggle con divs — el input nativo ya maneja ARIA
## Cuándo no usar
- Form multi-select or “agree to terms” → `Checkbox`. - Choosing one of many plans → `Radio` group.
## Criterio de uso
- Usa Toggle para una preferencia que cambia de inmediato y que tiene sólo dos estados persistentes. - Comunica el efecto del cambio en la etiqueta, no sólo con “on/off”; el usuario debe entender qué queda activo. - Si la preferencia está bloqueada por plan o permisos, explica el motivo cercano al control en lugar de dejar un switch muerto.
## Gotchas
- Un switch sin label visible o `aria-label` no tiene nombre accesible. - No uses Toggle para enviar un formulario ni para seleccionar una opción dentro de un conjunto mutuamente excluyente. - **Nota**: La animacion de bounce es puro CSS (cubic-bezier(0.35, 1.5, 0.6, 1) — spring con overshoot). Se activa con la clase .toggle--animated. Sin ella, el thumb se mueve instantaneamente. - **Nota**: No requiere GSAP. El bounce es puro CSS cubic-bezier. La diferencia con easing-spring (0.34, 1.56, 0.64, 1) del sistema: este es mas lento y con menos damping, diseñado para el slide fisico del thumb.
Uso
import { Toggle } from '@/components/atoms/Toggle';
<Toggle checked={on} onChange={setOn} label="Email alerts" />Props
| Prop | Tipo | Default | Rango / opciones | What | How |
|---|---|---|---|---|---|
| checked | boolean | false | `true` / `false` | On/off state of the switch. | Controlled: pass checked + onChange. Default uncontrolled depends on usage. |
| disabled | boolean | false | `true` / `false` | Prevents toggling. | true when the setting is locked by plan. Default: false. |
| animated | boolean | false | `true` / `false` | Thumb motion on toggle. | true by default for preferences; false if reduced-motion is required at the call site. Default: false. |
Gotchas
- a11y
Provide a visible label (label prop or Field) — a bare switch fails WCAG name.
Anatomía CSS
<div class="toggle"> <span class="toggle__input"></span> <span class="toggle__label"></span> <span class="toggle__thumb"></span> <span class="toggle__track"></span> </div>
| Clase | Propósito |
|---|---|
toggle | root |
toggle--animated | modifier |
toggle--disabled | modifier |
toggle__input | element |
toggle__label | element |
toggle__thumb | element |
toggle__track | element |
Tokens resueltos
Valores finales tras seguir la cadena de tokens. Derivados del source: si un token cambia, esta tabla cambia sola.
| Variante | Prop | Valor | Token |
|---|---|---|---|
| all | bg | #0a0a0a | foreground |
| all | border | #0a0a0a | foreground |
| all | hover-bg | #0a0a0a | foreground |
| all | disabled-bg | #e5e5e5 | border |
| all | fg | #0a0a0a | foreground |
| all | disabled-fg | #525252 | muted.foreground |
Animaciones
| Propiedad | Duración | Easing |
|---|---|---|
background-color | var(--duration-200) | var(--easing-out) |
border-color | var(--duration-200) | var(--easing-out) |
box-shadow | var(--duration-150) | var(--easing-out) |
transform | var(--duration-500) | cubic-bezier(0.35 |
1.5 | | |
0.6 | | |
1) | | |
CSS mínimo funcional
Autocontenido: sin imports ni tokens. Para previews y prototipos — en producción se consume el CSS del DS (@atom-uikit/css/components.css</code>, <code>@atom-uikit/tokens/tokens.css).
/* -------------------------------------------------------------------------
Toggle (Switch)
Single size. Track + thumb that slides on checked.
Native <input type="checkbox" role="switch"> hidden.
Off: muted track, white thumb with shadow
On: primary track, white thumb with shadow
States: unchecked, checked, disabled
Figma: Size=s (36x20), Size=m (44x24) — we use s (web standard)
------------------------------------------------------------------------- */
.toggle {
display: inline-flex;
align-items: center;
gap: 0.5em;
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
position: relative;
}
.toggle--disabled {
cursor: not-allowed;
pointer-events: none;
}
/* Hidden native input with touch target */
.toggle__input {
position: absolute;
width: 2.75rem;
height: 2.5rem;
margin: 0;
padding: 0;
opacity: 0;
cursor: inherit;
left: 0;
top: 50%;
transform: translateY(-50%);
}
/* Track */
.toggle__track {
position: relative;
width: 2.25rem;
height: 1.25rem;
flex-shrink: 0;
border-radius: 9999px;
border: 1px solid #e5e5e5;
background-color: #f5f5f5;
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.08);
transition:
background-color 200ms cubic-bezier(0.22, 1, 0.36, 1),
border-color 200ms cubic-bezier(0.22, 1, 0.36, 1),
box-shadow 150ms cubic-bezier(0.22, 1, 0.36, 1);
}
/* Thumb — absolute positioned for pixel-perfect alignment */
.toggle__thumb {
position: absolute;
top: 0.125rem;
left: 0.125rem;
width: 1rem;
height: 1rem;
border-radius: 9999px;
background-color: #fafafa;
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
transform: translateX(0) rotate(0.001deg);
}
/* Animated toggle: smooth slide with bounce overshoot */
.toggle--animated .toggle__thumb {
transition: transform 500ms cubic-bezier(0.35, 1.5, 0.6, 1);
}
/* ---- Hover ---- */
.toggle__input:hover:not(:disabled) ~ .toggle__track {
background-color: #e5e5e5;
}
.toggle__input:checked:hover:not(:disabled) ~ .toggle__track {
background-color: #0a0a0a;
}
/* ---- Checked ---- */
.toggle__input:checked ~ .toggle__track {
background-color: #0a0a0a;
border-color: #0a0a0a;
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.15);
}
.toggle__input:checked ~ .toggle__track .toggle__thumb {
transform: translateX(calc(2.25rem - 0.25rem - 1rem));
}
/* ---- Focus ---- */
.toggle__input:focus-visible ~ .toggle__track {
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.08), 0 0 0 2px var(--focus-ring-color);
}
/* ---- Disabled ---- */
.toggle__input:disabled ~ .toggle__track {
background-color: #f5f5f5;
}
.toggle__input:disabled:checked ~ .toggle__track {
background-color: #f5f5f5;
}
.toggle__input:disabled ~ .toggle__track .toggle__thumb {
background-color: #e5e5e5;
box-shadow: none;
}
/* ---- Label ---- */
.toggle__label {
font-family: 'inter tight', -apple-system, blinkmacsystemfont, 'segoe ui', roboto, helvetica, arial, sans-serif, ui-sans-serif, system-ui, sans-serif;
font-size: 12.8px;
line-height: 1;
color: #0a0a0a;
}
.toggle--disabled .toggle__label {
color: #525252;
}
Codigo fuente
import { forwardRef, type InputHTMLAttributes } from 'react'; export type ToggleProps = { checked?: boolean; disabled?: boolean; animated?: boolean; label?: string; className?: string; onChange?: (checked: boolean) => void; } & Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'checked' | 'onChange'>; function cn(...classes: (string | false | undefined | null)[]) { return classes.filter(Boolean).join(' '); } export const Toggle = forwardRef<HTMLInputElement, ToggleProps>( ( { checked = false, disabled = false, animated = false, label, className, onChange, ...props }, ref, ) => { const classes = cn( 'toggle', disabled && 'toggle--disabled', animated && 'toggle--animated', className, ); return ( <label className={classes}> <input ref={ref} type="checkbox" role="switch" className="toggle__input" checked={checked} disabled={disabled} onChange={(e) => onChange?.(e.target.checked)} {...props} /> <span className="toggle__track"> <span className="toggle__thumb" /> </span> {label && <span className="toggle__label">{label}</span>} </label> ); }, ); Toggle.displayName = 'Toggle';