¡Hola Devs! El problema del toggle tri-estado
Abres cualquier sitio moderno y ahí está: un selector de tema con Claro, Oscuro y Sistema. Se ve completo. Se ve respetuoso de la elección del usuario. Pero, siendo honestos, está compitiendo con lo que el visitante realmente quiere hacer.
Piensa: ¿por qué llega alguien a tu página? Quiere leer un artículo, comprar algo, resolver un pedo. El toggle de tema es tangencial a eso. Cada opción extra en un elemento persistente de UI es una micro-decisión que nadie pidió. Y la disonancia cognitiva aparece: "Espera, ¿cuál es la diferencia entre Oscuro y Sistema? ¿Sistema sigue mi SO? ¿Cuál está activo ahorita?"
La discusión que armó Lea Verou en CSS-Tricks lo resume bien: un control de dos estados puede expresar los tres — siempre que apliques el override la primera vez que haga diferencia.

El patrón de dos estados en código
La idea: arranca siguiendo el prefers-color-scheme del sistema y solo guarda un override en localStorage cuando el usuario le pica al toggle. Sin botón "Sistema".
// theme.js — toggle de dos estados con fallback del sistema
const root = document.documentElement;
const stored = localStorage.getItem('theme'); // 'light' | 'dark' | null
// 1. Resuelve el tema inicial: override guardado > preferencia del sistema
const sistemaPrefiereOscuro = window.matchMedia('(prefers-color-scheme: dark)').matches;
const temaInicial = stored ?? (sistemaPrefiereOscuro ? 'dark' : 'light');
root.dataset.theme = temaInicial;
// 2. Muestra solo la opción *opuesta* en la UI
function renderToggle() {
const actual = root.dataset.theme;
// Si está claro, ofrece "Oscuro"; si oscuro, ofrece "Claro".
document.getElementById('theme-toggle').textContent =
actual === 'dark' ? '☀️ Claro' : '🌙 Oscuro';
}
// 3. Al click: invierte, persiste, re-renderiza
document.getElementById('theme-toggle').addEventListener('click', () => {
const siguiente = root.dataset.theme === 'dark' ? 'light' : 'dark';
root.dataset.theme = siguiente;
localStorage.setItem('theme', siguiente); // override guardado
renderToggle();
});
// 4. Reacciona a cambios del SO SOLO si no hay override
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (localStorage.getItem('theme')) return; // el usuario ya eligió
root.dataset.theme = e.matches ? 'dark' : 'light';
renderToggle();
});
renderToggle();
/* style.css — variables CSS controladas por el atributo data-theme */
:root[data-theme="light"] {
--bg: #ffffff;
--fg: #111111;
}
:root[data-theme="dark"] {
--bg: #111111;
--fg: #f5f5f5;
}
body {
background: var(--bg);
color: var(--fg);
transition: background 0.2s ease, color 0.2s ease;
}
El truco está en el paso 4: los cambios del sistema se propagan solo hasta que el usuario decide. Después de eso, manda el localStorage. Y lo mejor: el visitante nunca ve un botón "Sistema", porque "Sistema" no es un estado que tenga que gestionar — es el fallback natural.

Cuándo sí valen la pena tres estados
Aguanta, no borres todos los toggles tri-estado de tu codebase. El patrón de dos estados aplica para toggles persistentes y siempre visibles — esos del header que se pelean con el objetivo principal del visitante. Tres estados sí tienen sentido en dos escenarios bien específicos:
1. El toggle vive en un panel de ajustes
Si el control de tema está enterrado en una página de Preferencias — donde el usuario llegó a propósito — el costo cognitivo de la tercera opción se cae a casi cero. En ese contexto, los radios explícitos de Claro/Oscuro/Sistema son hasta más claros que un flip ingenioso de dos estados.
2. Ofreces más de dos esquemas de color
Si tu sitio trae Solarized, Nord, Dracula y un modo de alto contraste junto con el default del sistema, "Sistema" se vuelve una quinta opción real en vez de una tercera redundante. El patrón de dos estados asume exactamente dos esquemas.
Ojo: el caso borde del toggle de regreso
¿Qué pasa si el usuario arranca en Sistema (Claro), se va a Oscuro y regresa? ¿Cae en Sistema otra vez o en un override Claro? La respuesta honesta: da igual. Como dice Lea, la divergencia solo se vuelve observable cuando el SO cambia a oscuro después — y en ese momento, corregir es un click. No sobre-ingenierices una máquina de estados para un control que el visitante apenas nota.
La alternativa real: cero toggles
Si el diseño de tu sitio aguanta bien en ambos esquemas, considera entregar cero toggles y confiar 100% en prefers-color-scheme. El navegador ya expone esto en los ajustes del SO. Un toggle es una feature que hay que mantener, probar en varios browsers y explicar. A veces el mejor UX es el que no construyes.

El resumen
Menos UI haciendo lo mismo que más UI es casi siempre la decisión correcta — sobre todo para controles que se meten entre el visitante y su objetivo. El patrón de dos estados te da:
- Control total del usuario (override persistido en
localStorage) - Preferencia del sistema respetada (vía
prefers-color-scheme) - Un botón menos saturando tu navegación
Si mantienes una librería de componentes o design system, pon esto como default. Reserva los toggles tri-estado para páginas de ajustes dedicadas y temas multi-esquema. Y si te late ver cómo el tooling moderno de IA on-device se está conectando en apps móviles, el tutorial de ADK para Kotlin & Android es una buena siguiente lectura — el mismo principio de "menos superficie, más claridad" aplica a UIs de agentes. Para equipos que despliegan infra de inferencia detrás de estas experiencias, la guía del SageMaker HyperPod inference operator cubre el lado backend.
Próximos pasos: audita el header de tu sitio buscando controles redundantes, corre un pase rápido de accesibilidad en tu prefers-color-scheme (¿respeta prefers-contrast?) y checa si tu toggle se anuncia correctamente a lectores de pantalla vía aria-pressed.