Nueve técnicas de modo oscuro comparadas, desde trucos con filtros hasta cookies del servidor
Compara nueve formas de agregar modo oscuro a una aplicación web, desde filtros invertidos hasta tokens, light-dark() y cookies del servidor, y aprende qué errores introduce silenciosamente cada una de ellas.
El modo oscuro suele presentarse como una opción entre un arreglo rápido y la solución adecuada, pero los sitios en producción utilizan al menos nueve técnicas distintas, y cada una de ellas soluciona solo parte del problema. Las partes que una técnica ignora tienden a manifestarse posteriormente como páginas parpadeantes, encabezados fijos dañados o colores que se niegan silenciosamente a cambiar. Esta guía clasifica esas nueve metodologías, explica qué hace bien y qué mal cada una, y luego aborda los detalles que dificultan incluso las implementaciones más cuidadosas: el uso del tema incorrecto, la propiedad color-scheme, las preferencias de tres estados, las transiciones, el contenido incrustado y el diseño de la paleta.
Las afirmaciones sobre el comportamiento que se presentan a continuación se basan en material primario: borradores del Grupo de Trabajo CSS, el Estándar HTML de WHATWG, los datos browser-compat-data legibles por máquina que sustentan MDN, los datos de estado de referencia y el código fuente de la biblioteca next-themes, verificados cruzadamente con Chromium sin interfaz gráfica. Dos creencias populares no resisten ese escrutinio, y un comportamiento concreto, filter que afecta a los descendientes con position: fixed, resulta ser una razón mucho más válida para evitar el truco de inversión que el vago argumento de rendimiento que suele presentarse.
El modo oscuro implica tres problemas separados
“Añadir un tema oscuro” suena como una sola tarea. En la práctica, abarca tres cuestiones lo suficientemente independientes como para que, incluso si respondes perfectamente a una de ellas, el resultado siga siendo defectuoso:
- ¿Qué tema se debe mostrar? El sistema operativo tiene una preferencia, el usuario puede querer anularla y también necesita una forma de volver a seguir la configuración del sistema. Un simple interruptor de encendido/apagado elimina por completo esa tercera opción.
- ¿Cómo cambian los colores? Un único interruptor debe actualizar cada superficie, borde, ícono y sombra, y su ubicación define efectivamente la arquitectura CSS del sitio.
- ¿Cuándo se aplica el tema? Si la decisión se toma después de que se haya dibujado la página por primera vez, los usuarios ven cómo cambian los colores ante sus ojos.
Cada técnica que se describe a continuación responde a alguna parte de estas preguntas. El patrón es claro: los llamados enfoques “perezosos” suelen tratar únicamente la segunda pregunta de forma aislada e ignoran por completo la primera y la tercera.
Cómo leer la clasificación
Los tres primeros opciones no son rivales. Se combinan en una sola configuración: los tokens semánticos constituyen la base, light-dark() es una forma más compacta de escribir esos tokens, y una cookie legible por el servidor sirve para entregar el tema elegido sin necesidad de Flash. Las posiciones cuatro a seis representan verdaderos compromisos entre los cuales hay que elegir uno. Las posiciones siete a nueve corresponden a deuda técnica.
Posición 9: invertir toda la página con filter
El modo oscuro más simple posible aplica una inversión y una rotación de tono al elemento raíz:
html {
filter: invert(1) hue-rotate(180deg);
}
Inmediatamente se aplica una segunda regla que invierte nuevamente cada elemento multimedia para que las fotos y los videos vuelvan a verse normales:
/* now patch back everything it broke */
img, video, canvas, svg, [style*="url("] {
filter: invert(1) hue-rotate(180deg);
}
La objeción que la gente suele plantear es la relacionada con el rendimiento, algo difícil de demostrar de manera clara. Existe una objeción mucho más importante. La documentación de MDN sobre los bloques contenedores lo explica claramente: un filter establecido en cualquier valor distinto a none convierte al elemento en el bloque contenedor para los descendientes con position: fixed y position: absolute. También crea un nuevo contexto de apilamiento, lo que modifica silenciosamente la forma en que se resuelve cada z-index que se encuentre debajo de él.
Una prueba sin interfaz gráfica hace esto más concreto. Coloque una barra fija dentro de un contenedor filtrado en una página de 3000 píxeles de altura en Chromium 141, desplácese 400 píxeles hacia abajo y mida dónde se encuentra la barra:
await p.evaluate(() => window.scrollTo(0, 400));
// -> { "fixed_viewportTop": 100, "abs_viewportTop": 100 }
// A truly viewport-fixed element reports top: 0 after any scroll.
La barra indica un desplazamiento de la vista de 100 en lugar de 0, por lo que ya no está fija; se desplaza junto con el contenido. Al aplicarse al html, el filtro rompe todos los encabezados adheridos, las navegaciones fijas, las superposiciones modales, las pilas de notificaciones y los cajones deslizantes de la página. Se trata de un defecto de corrección que se puede reproducir con unas pocas líneas de código, no de una cuestión de gusto.
Los problemas restantes son conocidos:
- Cada recurso rasterizado necesita una inversión invertida, y los logotipos con colores de marca integrados siguen saliendo mal, ya que rotar el tono 180 grados no constituye una inversión precisa en ningún espacio de color.
- Los colores de marca se convierten en sus opuestos matemáticos en lugar de formar una paleta oscura diseñada específicamente.
filter: grayscale(50%) en las fotografías y invert(100%) únicamente para los iconos en blanco y negro.Rangos 8 y 7: enfoques que funcionan hasta que dejan de hacerlo
Sobrescrituras por componente
Escribir una variante oscura para cada componente es el primer intento natural. No está tan mal como ser ilimitado en su aplicación. Se comienza con estilos claros:
.card { background: #fff; color: #14161a; }
.card .btn { background: #f0f2f5; }
y luego se añaden las versiones oscuras bajo una clase body:
body.dark .card { background: #121212; color: #fff; }
body.dark .card .btn { background: #333; }
body.dark .card .btn:hover { background: #444; }
Dado que las reglas oscuras deben superar a las claras, los selectores se vuelven cada vez más específicos; body.dark .card .btn:hover ya cuenta con cuatro partes desde el primer día. Los valores hexadecimales también varían, con #121212 en un archivo, #111 en otro y #0f0f0f en un componente copiado, sin que exista un lugar central para verificar automáticamente el contraste. El problema principal de escalado se resume en una frase: las sobrescripciones aumentan con la cantidad de componentes, mientras que los tokens crecen con el número de roles, y la mayoría de los sistemas de diseño se estabilizan en aproximadamente 12 a 20 roles.
Dos hojas de estilo separadas
Cargar una hoja de estilo clara y otra oscura mediante atributos media parece eficiente:
<link rel="stylesheet" href="light.css" media="(prefers-color-scheme: light)">
<link rel="stylesheet" href="dark.css" media="(prefers-color-scheme: dark)">
No evita la segunda descarga, que es donde la gente comete errores. Como explica el artículo de web.dev sobre prefers-color-scheme, la hoja de estilo cuya consulta multimedia no coincide sigue siendo cargada, solo con la menor prioridad para que no compita con los recursos que la página necesita actualmente. La ventaja es un camino crítico más corto, no menos bytes. Además, el atributo media solo lee la configuración del sistema operativo, por lo que ningún cambio manual puede influir en ella; los dos archivos tienden a separarse con el tiempo; y los herramientas de empaquetado pueden manejarlos incorrectamente, como se documenta en un problema de Vite.
Rango 6: un interruptor puramente en CSS con :has()
Una casilla de verificación visualmente oculta y su etiqueta pueden funcionar como el interruptor:
<input type="checkbox" id="theme" class="sr-only">
<label for="theme">Dark mode</label>
Luego, la raíz reacciona al estado de la casilla de verificación mediante :has():
html:has(#theme:checked) {
color-scheme: dark;
--bg-surface: #1b1f27;
--text-1: #e8e6e3;
--border: #2b313c;
}
En realidad no se necesita JavaScript, y :has() alcanzó el nivel “ampliamente disponible” el 19 de junio de 2026. Hay dos puntos importantes. Primero, cambiar los tokens de color así como color-scheme, tal como lo hace el ejemplo. Segundo, el estado solo existe en el DOM, por lo que cada carga de página comienza desde cero y nada se guarda. Tampoco puede representar tres estados de manera ordenada; eso requeriría botones de radio y más ramas de selección. Es adecuado para una demostración, CodePen o un documento de página única, pero no para un producto.
Rango 5: la variante dark: de Tailwind
La variante dark: no está mal, pero coloca las decisiones de color en el lugar incorrecto: en las plantillas, repetidas en cada punto de llamada.
<div class="bg-white dark:bg-zinc-900
text-zinc-900 dark:text-zinc-100
border-zinc-200 dark:border-zinc-800
hover:bg-zinc-50 dark:hover:bg-zinc-800">
El resultado son cadenas largas de clases que se dispersan, lo que impide que cualquier script las audite ya que los valores oscuros están distribuidos por los templates. Además, la existencia de temas adicionales como alto contraste o skins de marca solo multiplica el marcado en lugar de añadir una capa adicional. Tampoco soluciona los problemas de la interfaz gráfica dibujada por el navegador y sigue requiriendo un script separador para evitar destellos.
La solución se encuentra dentro de Tailwind mismo. Asocia los colores del tema a variables CSS y cámbialas una sola vez; luego puedes usar bg-surface sin ningún prefijo dark:. Comienza con la importación estándar:
@import "tailwindcss";
Luego define una variante personalizada, asigna los colores del tema a las variables y otorga a cada tema sus propios valores de variable:
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));@theme {
--color-surface: var(--surface);
--color-content: var(--content);
}:root { --surface: #f4f5f7; --content: #14161a; color-scheme: light; }
[data-theme="dark"] { --surface: #1b1f27; --content: #e8e6e3; color-scheme: dark; }
[data-theme="hc"] { --surface: #000000; --content: #ffffff; color-scheme: dark; }
El wrapper :where() es intencional. Le da a la variante oscura cero especificidad adicional, de modo que las utilidades dark: nunca sobrescriben accidentalmente estilos no relacionados. Añadir el tema de alto contraste solo requiere una línea en lugar de procesar cada plantilla.
Rango 4: seguir únicamente prefers-color-scheme
La opción más simple y robusta es definir los tokens claros por defecto y redefinirlos dentro de una consulta multimedia:
:root {
color-scheme: light;
--bg-base: #ffffff; --text-1: #14161a;
}
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
--bg-base: #12141a; --text-1: #e8e6e3;
}
}
No hay JavaScript, ni flash y nada que se deba cargar dinámicamente. Este es el enfoque más rápido de toda la lista, y su único inconveniente es decisivo para las aplicaciones e irrelevante para el contenido: los usuarios no pueden sobrescribir la configuración del sistema.
Si nadie ha solicitado nunca una opción de alternancia, como es común en blogs, documentación, registros de cambios y páginas de marketing, deténgase aquí. Nada en la lista a continuación supera esta opción para ese tipo de sitios.
Hay dos detalles de la especificación que vale la pena conocer:
- Media Queries Nivel 5 advierte que esta función podría incluir más valores en el futuro, siendo sepia el ejemplo dado, y recomienda probar mediante negación:
(prefers-color-scheme: dark)frente a(not (prefers-color-scheme: dark)), en lugar de coincidir explícitamente conlight. - El valor
no-preferenceha sido eliminado. Está ausente de la especificación actual y ningún navegador lo implementa; un usuario sin preferencia coincide conlight.
Rango 3: light-dark() y su modo de fallo silencioso
light-dark() reduce aproximadamente a la mitad el tamaño de un archivo de tokens, ya que una sola declaración contiene ambos valores:
:root { color-scheme: light dark; } /* REQUIRED */
.card {
background: #fff; /* fallback for old browsers */
background: light-dark(#fff, #1b1f27);
color: light-dark(#14161a, #e8e6e3);
border-color: light-dark(#e2e5ea, #2b313c);
}
/* a manual override becomes ONE property write */
[data-theme="dark"] { color-scheme: dark; }
[data-theme="light"] { color-scheme: light; }
Se trata de un sistema de temas completo sin ningún bloque @media ni segunda regla :root. La modificación manual se reduce a establecer color-scheme en la raíz.
Pero esto hace que los equipos pierdan horas. Al probar varias variantes en Chromium 141 con el esquema de colores forzado de ambas maneras, se obtuvieron tres hallazgos importantes:
- Sin
color-scheme,light-dark()no hace nada. En un sistema oscuro devuelve silenciosamente los colores claros, y no hay indicio alguno en la consola del problema. Este es el error más común delight-dark(), y permanece invisible hasta que alguien en un sistema oscuro lo reporta.
color-scheme: dark en un elemento, este adoptará el segundo argumento, independientemente de lo que indique el sistema operativo. Por eso, un cambio manual consiste en escribir una sola propiedad en lugar de intercambiar clases y aplicar un conjunto paralelo de reglas.color-scheme: dark en un elemento que no sea la raíz, este no obtiene un fondo oscuro. Se modifican los colores del sistema y los controles nativos, pero el fondo del canvas solo sigue al de la raíz. Este es el malentendido más común sobre esta propiedad.Como verificación para tu propia paleta, en modo oscuro Chromium calcula el color del sistema Canvas como rgb(18, 18, 18), que corresponde a #121212, el mismo color de fondo básico que sugiere Material para temas oscuros.
Antes de confiar en esto, ten en cuenta estas consideraciones:
- Se trata de una línea base “recién disponible” y no “ampliamente disponible”: Chrome y Edge 123, Firefox 120 y Safari 17.5; la fecha de disponibilidad reciente es 2024-05-13, lo que sitúa el umbral de disponibilidad amplia alrededor del 2026-11-13.
- No permite una degradación elegante. Los navegadores que no lo soportan descartan toda la declaración como inválida, por lo que siempre se debe incluir primero un fallback sencillo para la misma propiedad, como se muestra en el ejemplo.
- Es un valor de color, por lo que no puede utilizarse como condición en consultas de medios. Usarlo dentro de declaraciones dentro de un bloque
@mediaestá bien; es algo distinto. - Los argumentos de imagen, como en
light-dark(url(a.png), url(b.png)), solo llegaron a Chrome 150, Firefox 150 y Safari 27 según los datos de compatibilidad en el momento de redactar este texto, que es demasiado reciente como para confiar en ellos.
Una advertencia sobre la documentación: la página de prosa de MDN para light-dark() menciona Chrome 119 y Safari 17.2, mientras que los datos browser-compat-data de MDN y la API Baseline indican 123 y 17.5 respectivamente. Cuando hay discrepancias, hay que confiar en los datos estructurados y verificar las páginas actuales antes de citar versiones.
Rango 2: tokens semánticos, la capa que todo lo demás necesita
La regla clave es nombrar el papel que desempeña un color, nunca el color en sí. Comience con los valores primitivos, es decir, los valores de paleta brutos a los que los componentes nunca hacen referencia directamente:
/* primitives: raw values, never consumed by components */
:root {
--gray-0: #ffffff; --gray-50: #f4f5f7; --gray-200: #e2e5ea;
--gray-600: #55606e; --gray-900: #14161a; --gray-950: #12141a;
--blue-500: #3b82f6; --blue-400: #60a5fa;
}
Sobre ellos se encuentra una capa semántica. Los valores claros son el valor por defecto; un único bloque [data-theme="dark"] reemplaza a todos ellos, y los componentes solo utilizan nombres de roles, por lo que nunca necesitan saber qué tema está activo:
/* semantic roles: light is the default */
:root {
color-scheme: light;
--bg-base: var(--gray-0);
--bg-surface: var(--gray-50);
--text-1: var(--gray-900);
--text-2: var(--gray-600);
--border: var(--gray-200);
--accent: var(--blue-500);
--shadow-sm: 0 1px 2px rgb(0 0 0 / 0.08);
}/* one block flips the whole app */
[data-theme="dark"] {
color-scheme: dark;
--bg-base: #12141a; /* grey, not #000 */
--bg-surface: #1b1f27; /* lighter = higher up */
--bg-raised: #232833; /* lighter still */
--text-1: #e8e6e3;
--text-2: #a2acbb;
--border: #2b313c;
--accent: var(--blue-400);
--shadow-sm: 0 1px 2px rgb(0 0 0 / 0.5);
}/* components never know which theme is active */
.card { background: var(--bg-surface); color: var(--text-1); border: 1px solid var(--border); }
Fíjese en los detalles del bloque oscuro: la base es gris oscuro en lugar de negro, las superficies se vuelven más claras a medida que están más arriba, el acento cambia a un tono más claro, y la sombra se vuelve más intensa para mantenerse visible.
Una prueba sencilla le indica si el nombre de un token es adecuado: ¿puede describir cuándo usarlo sin mencionar un color? “Fondo de un panel elevado” describe una función; “gris claro” describe una muestra de color. Solo las funciones sobreviven al cambio de tema, ya que un token llamado literalmente gris claro nunca debería volverse oscuro.
En cuanto al propio interruptor, el atributo data-theme es mejor que la clase .dark. Puede contener naturalmente tres o más valores, no puede entrar en conflicto con las clases utilitarias, y cambiarlo consiste únicamente en asignar un valor a document.documentElement.dataset.theme. Para obtener más información sobre el uso de propiedades personalizadas en tiempo de ejecución, consulte la guía del blog sobre el tema con propiedades personalizadas de CSS.
Rango 1: leer una cookie de tema en el servidor
Renderizar el tema en el servidor es la única opción que evita los cuatro costos habituales: un script inline, una animación de carga, una discrepancia en la hidratación y una excepción en la Política de Seguridad de Contenido, ya que el servidor conoce el tema antes de enviar el primer byte. En un proyecto Next.js App Router, el layout raíz importa la herramienta para manejar cookies:
// app/layout.tsx
import { cookies } from 'next/headers';
y escribe el tema almacenado directamente en el elemento html, recayendo en el tema claro:
export default async function RootLayout({ children }) {
const store = await cookies(); // async since Next 15
const theme = store.get('theme')?.value ?? 'light'; return (
<html lang="en" data-theme={theme} style={{ colorScheme: theme }}>
<body>{children}</body>
</html>
);
}
El cambio de tema se realiza mediante una acción en el servidor, la cual necesita la misma importación:
// app/actions.ts
'use server';
import { cookies } from 'next/headers';
La acción almacena la elección por un año, con alcance en todo el sitio:
export async function setTheme(theme: 'light' | 'dark') {
const store = await cookies();
store.set('theme', theme, { path: '/', maxAge: 60 * 60 * 24 * 365, sameSite: 'lax' });
}
Los costos están documentados por Next.js mismo:
cookies()es una API que se ejecuta en el momento de la solicitud, por lo que llamarla en un layout o página cambia esa ruta a renderizado dinámico y se pierde el renderizado previo estático.- Con los Componentes de Caché habilitados, llamar a
cookies()fuera de los límites de un<Suspense>también impide el renderizado previo. - HTTP no permite establecer cookies una vez que ha comenzado la transmisión, por lo que la cookie debe escribirse con
.seten una Función de Servidor o Manejador de Ruta, nunca durante el renderizado.
system aún debe resolverse en el cliente. La combinación práctica es utilizar la cookie para las elecciones explícitas y matchMedia en el caso del sistema operativo.Existe una indicación del cliente, Sec-CH-Prefers-Color-Scheme, que permitiría exponer la preferencia del sistema operativo al servidor. Se trata solo de un borrador del WICG, está disponible únicamente en Chromium y no es un estándar; por lo tanto, considérela como una optimización a lo sumo, no como una solución general.
Evitar la aparición del tema incorrecto
Este es el tercer problema desde el inicio, y el defecto más común en modo oscuro en los sitios ya publicados. El debate sobre “el método correcto frente al método negligente” generalmente no lo menciona en absoluto. El cronograma muestra por qué un script diferido llega demasiado tarde:
DEFERRED SCRIPT: [HTML][CSS][PAINT: LIGHT][JS][REPAINT: DARK] <- user sees it
BLOCKING INLINE: [HTML][JS][CSS][PAINT: DARK] <- correct first paint
El tema debe establecerse en html antes de la primera pintura. La solución es un pequeño script inline en el head, colocado antes de la hoja de estilo:
<head>
<meta charset="utf-8">
<meta name="color-scheme" content="light dark">
<script>
// inline. no src, no defer, no async, no type=module.
(function () {
try {
var s = localStorage.getItem('theme'); // 'light'|'dark'|'system'|null
var dark = s === 'dark' ||
((!s || s === 'system') &&
matchMedia('(prefers-color-scheme: dark)').matches);
var el = document.documentElement;
el.dataset.theme = dark ? 'dark' : 'light';
el.style.colorScheme = dark ? 'dark' : 'light';
} catch (e) { /* storage throws in private mode / sandboxed iframes */ }
})();
</script>
<link rel="stylesheet" href="/app.css">
</head>
Cada restricción en ese fragmento es importante. Debe ser inline y síncrono, sin src, defer, async ni tipo de módulo, ya que cualquiera de estos elementos permitiría al navegador dibujar primero. Lee una preferencia de tres valores y resuelve system a través de matchMedia. Establece tanto el atributo como colorScheme, para que los tokens y la interfaz del navegador estén alineados. Además, envuelve el acceso al almacenamiento en try/catch, porque localStorage lanza errores en modos de navegación privada e iframes aislados.
El verdadero costo es la política de seguridad: un script inline requiere 'unsafe-inline' o un nonce en tu CSP. Si tu política no permite ninguno de los dos, utiliza el enfoque con cookies.
En Next.js también es necesario utilizar suppressHydrationWarning en el elemento html, ya que el script modifica sus atributos antes de que React realice la hidratación, y estos ya no coinciden con el marcado del servidor. Tal como indica la documentación de next-themes, esta opción solo tiene efecto a un nivel, por lo que no oculta las advertencias de hidratación en ningún otro lugar.
Al leer el código fuente de next-themes se puede ver cuán poco está desarrollada su funcionalidad de prevención de flash. Este componente renderiza un <script dangerouslySetInnerHTML> cuyo contenido es su propia función script(), convertida a cadena con script.toString() y ejecutada de inmediato con argumentos serializados en JSON. Su gancho useTheme() devuelve theme, setTheme, resolvedTheme, systemTheme y themes; sin embargo, theme es undefined durante la renderización en el servidor. Renderice su botón de alternancia a partir de resolvedTheme tras verificar que el componente esté montado, ya que de lo contrario el propio botón causará un error de hidratación.
color-scheme: la propiedad que la mayoría de los sitios nunca configuran
Tu hoja de estilos determina el aspecto de lo que escribes, pero el propio navegador dibuja las barras de desplazamiento, los controles de formulario nativos y el área de dibujo detrás de la página. La especificación de Ajuste de Color CSS exige que el agente del usuario coincida con todo esto con el esquema de colores del elemento:
- los colores predeterminados de las barras de desplazamiento y la interfaz de usuario interactiva
- el aspecto predeterminado de los controles de formulario
- elementos adicionales de la interfaz del navegador, como las subrayados de corrección ortográfica
- colores del sistema como
Canvas,CanvasText,ButtonFace,FieldyAccentColor - el resultado de
light-dark()
En el elemento raíz, el esquema también controla el color de la superficie del lienzo y las barras de desplazamiento de la vista. Debe declararse en tres lugares: primero, una etiqueta meta que el analizador HTML ve antes de que llegue cualquier CSS:
<!-- parsed at HTML-parse time, BEFORE any CSS loads -->
<meta name="color-scheme" content="light dark">
Segundo, reglas CSS que mantienen la propiedad sincronizada con el atributo de tema:
:root { color-scheme: light dark; }
[data-theme="dark"] { color-scheme: dark; }
[data-theme="light"]{ color-scheme: light; }
Tercero, cuando sea necesario, un mecanismo de bloqueo que mantiene un widget específico en modo claro independientemente del tema:
/* force a widget to stay light regardless */
.brand-widget { color-scheme: only light; }
La etiqueta meta no es redundante. La sección del estándar HTML sobre la meta de esquema de colores existe precisamente para que el navegador pueda pintar el fondo de la página con el esquema adecuado de inmediato, sin esperar a las hojas de estilo. La propiedad CSS solo se conoce después de que la hoja de estilo haya sido descargada y analizada; ese intervalo provoca un destello en un fondo blanco. El estándar también permite como máximo un elemento meta de este tipo por documento.
Otras dos dificultades:
- La propiedad y la consulta de medios no están relacionadas. Declarar
color-scheme: darknunca hace queprefers-color-scheme: darkse aplique, por lo que el código que intente deducir uno a partir del otro funcionará incorrectamente.
only indica al navegador que no puede anular el esquema del elemento. En la práctica, es la forma en que una página impide que Chrome en Android aplique su tema oscuro automático. Su historial de compatibilidad es extraño: se añadió en Chrome 81, se eliminó en 85 y se restauró en 98.Tres estados en lugar de un booleano
En cuanto la opción se convierte en booleana, “seguir mi SO” desaparece y el usuario no puede recuperarla. La preferencia necesita tres valores: claro, oscuro y sistema. Comience con una clave de almacenamiento y una lista de consultas de medios:
const STORAGE_KEY = 'theme';
const mq = matchMedia('(prefers-color-scheme: dark)');
El resto de la lógica aplica una preferencia, la guarda, la vuelve a leer con system como valor por defecto, y sigue escuchando los cambios del SO solo mientras esté seleccionado system:
function apply(pref) { // 'light' | 'dark' | 'system'
const dark = pref === 'dark' || (pref === 'system' && mq.matches);
const el = document.documentElement;
el.dataset.theme = dark ? 'dark' : 'light';
el.style.colorScheme = dark ? 'dark' : 'light';
}function setPreference(pref) {
try { localStorage.setItem(STORAGE_KEY, pref); } catch (e) {}
apply(pref);
}function getPreference() {
try { return localStorage.getItem(STORAGE_KEY) || 'system'; }
catch (e) { return 'system'; }
}// keep following the OS, but ONLY while 'system' is the chosen preference
mq.addEventListener('change', () => {
if (getPreference() === 'system') apply('system');
});apply(getPreference());
Use addEventListener en el MediaQueryList, no addListener. MediaQueryList ahora hereda de EventTarget, y addListener junto con removeListener están obsoletos, aunque muchos ejemplos en línea siguen utilizando ellos. next-themes mantiene intencionadamente estos métodos obsoletos, con un comentario en el código que lo indica, para compatibilidad con versiones antiguas de Safari.
El control debe ser un grupo de tres botones de opción, no una casilla de verificación, ya que tres estados requieren tres entradas.
Detener la mancha de color durante un cambio
Si los elementos de la página tienen transiciones de color, al cambiar de tema se animan cientos de propiedades al mismo tiempo y la página se vuelve borrosa visualmente. La solución que utiliza next-themes en su opción disableAnimation inyecta un estilo temporal que desactiva todas las transiciones:
function disableTransitionsTemporarily(nonce) {
const css = document.createElement('style');
if (nonce) css.setAttribute('nonce', nonce);
css.appendChild(document.createTextNode(
`*,*::before,*::after{ transition: none !important }`
));
document.head.appendChild(css);
Devuelve una función que restaura las transiciones, y una ayuda swapTheme encapsula el cambio de tema entre ambas acciones:
return () => {
// Deliberate forced synchronous reflow: commit the new colours
// WHILE transitions are still off.
(() => window.getComputedStyle(document.body))();
// Remove on a later task, after the flush has committed.
setTimeout(() => { document.head.removeChild(css); }, 1);
};
}function swapTheme(next) {
const enable = disableTransitionsTemporarily();
apply(next);
enable();
}
La llamada a getComputedStyle parece código muerto y a menudo se elimina. En realidad es un vaciado sincrónico forzado de estilos que aplica los nuevos colores mientras las transiciones siguen desactivadas. La eliminación posterior se programa para un momento futuro con setTimeout, una vez que el vaciado ha surtido efecto. Sin este reestilizado forzado ni la eliminación diferida, aún se observa un desvanecimiento parcial.
Si prefiere que el cambio sea un efecto visible, la API de transiciones de vista admite el conocido efecto de revelación circular. Está disponible en la versión Baseline desde el 14-10-2025, con Chrome 111, Safari 18 y Firefox 144. Hay un requisito que es fácil pasar por alto: desactive las animaciones predeterminadas en los snapshots raíz y establezca el modo de mezcla en el snapshot anterior:
::view-transition-old(root) { animation: none; mix-blend-mode: normal; }
::view-transition-new(root) { animation: none; }
También tenga en cuenta a los usuarios que han solicitado menos movimiento:
@media (prefers-reduced-motion) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) { animation: none !important; }
}
No elimine mix-blend-mode: normal del snapshot anterior. Con el modo de mezcla predeterminado plus-lighter, la pantalla se vuelve brevemente de color blanco lechoso durante el cambio de oscuro a claro, lo que se parece y se reporta como un error de destello.
Qué sigue fallando después del cambio de tokens
Imágenes y SVG
El elemento <picture> con media="(prefers-color-scheme: dark)" sigue únicamente la configuración del sistema operativo. No puede detectar el atributo de tema ni el valor de color-scheme, por lo que activar manualmente este ajuste hace que las imágenes no estén sincronizadas con el resto de la interfaz, lo cual es un error común en las versiones lanzadas. Las imágenes de fondo declaradas dentro del bloque correspondiente al tema oscuro sí se ajustan al cambio de modo. En el caso de los SVG, currentColor funciona en <svg> incrustado y en <svg><use href="…">, pero no en los SVG cargados mediante <img src> o CSS url(), ya que se trata de documentos separados que nunca heredan el valor de color. O bien incluya las consultas prefers-color-scheme dentro del propio archivo SVG, o bien utilice mask-image junto con background-color: currentColor.
Iframes
La especificación de ajuste de colores indica que, cuando el esquema de colores de un iframe difiere del de la raíz del documento incrustado, el navegador debe dibujar un lienzo opaco con el color Canvas del documento incrustado en lugar de uno transparente. En la práctica, esto genera un rectángulo blanco en una página oscura. Asignar al elemento <iframe> un atributo color-scheme corrige ese primer lienzo, pero el CSS propio de un documento de origen distinto sigue sin estar disponible. YouTube, Stripe Elements, Disqus y Turnstile exponen cada uno una opción de tema separada; no existe solución a nivel CSS.
theme-color
El soporte para la meta theme-color es mucho más débil de lo que la gente espera. Firefox no lo ha soportado en ninguna plataforma; Chrome para escritorio, a partir de la versión 73, solo lo aplica a las PWA instaladas; Safari lo adoptó en la versión 15, pero a partir de Safari 26 solo lo respeta en las aplicaciones web instaladas. MDN lo indica como de disponibilidad limitada, y no como de estado básico. La especificación también permite a los navegadores ajustar el color según lo consideren adecuado, por ejemplo oscureciéndolo para mantener un contraste suficiente, por lo que no se debe confiar en una renderización exacta.
Colores forzados y preferencias de contraste
En el modo de alto contraste de Windows (forced-colors: active), el navegador toma el control de propiedades como background-color, color, border-color, outline-color, text-decoration-color, así como de los valores fill y stroke en SVG. Tanto box-shadow como text-shadow se restablecen a none, y color-scheme se fija en light dark. Cualquier efecto de elevación que dependa de sombras desaparece, por lo que hay que reemplazarlo con bordes en los colores del sistema:
@media (forced-colors: active) {
/* your shadow-based elevation is gone — replace it */
.card { border: 1px solid CanvasText; box-shadow: none; }
.btn { border: 1px solid ButtonText; }
}
El color del sistema que recibe un elemento depende de su semántica HTML nativa, no de su rol ARIA; por eso un <div role="button"> no recibe el atributo ButtonText. MDN insiste en que no se debe crear un diseño separado para los colores forzados, sino solo realizar pequeños ajustes para mejorar la legibilidad.
Otro eje que a menudo se olvida por completo es prefers-contrast, cuyos valores son no-preference, more, less y custom. Baseline está ampliamente disponible desde el 31-05-2022 e independe del esquema de colores. Los temas oscuros que nunca consideran el valor more representan una deficiencia frecuente en accesibilidad.
Diseño de la paleta oscura
Use gris oscuro, no negro
Las directrices de Material recomiendan utilizar gris oscuro en lugar de negro para fondos y superficies oscuras, ya que el gris mantiene visibles las sombras y reduce la fatiga ocular al leer texto claro. El codelab de temas oscuros de Google menciona que el texto en color puro #FFFFFF sobre un fondo oscuro puede parecer borroso o vibrante, lo que afecta su legibilidad.
Diga esto con cuidado. La idea repetida con frecuencia de que el negro puro causa el efecto de “halación” no parece estar respaldada por ningún estudio controlado. Lo que sí está respaldado es el efecto de sangrado y vibración del texto blanco puro, así como la elección del gris en lugar del negro, justificada por la visibilidad de las sombras y el esfuerzo ocular.
Exprese la elevación con ligereza
Las sombras funcionan mal en temas oscuros, por lo que Material compensa haciendo que las superficies sean más claras y ligeramente más coloridas a medida que se elevan. color-mix() facilita obtener esto a partir de una sola base:
[data-theme="dark"] {
--surface-1: #12141a;
--surface-2: color-mix(in oklab, var(--surface-1) 92%, white);
--surface-3: color-mix(in oklab, var(--surface-1) 84%, white);
--surface-4: color-mix(in oklab, var(--surface-1) 76%, white);
}
color-mix() está ampliamente disponible desde el 2025-11-09 como parte de Baseline. Tenga en cuenta que el sistema de superposiciones elevation-overlay de Material Design 2 está obsoleto: la documentación de Google indica que dichas superposiciones fueron reemplazadas por el sistema de colores de superficie tonal y ya no se mantienen. Material 3 utiliza roles desde surfaceContainerLowest hasta surfaceContainerHighest, además de surfaceDim y surfaceBright. Una referencia que muestra la antigua tabla con valores del 5 por ciento a 1dp hasta el 16 por ciento a 24dp hace referencia a un sistema obsoleto.
Desaturar los acentos
Los acentos de tono medio saturados vibran sobre superficies oscuras. OKLCH hace que el ajuste sea sistemático: su canal de luminosidad es perceptualmente uniforme, por lo que los pasos numéricamente pares también se ven uniformes, algo en lo que falla HSL y por qué las rampas oscuras en HSL se vuelven turbias en el medio. Un acento más claro y menos cromático para el tema oscuro se ve así:
:root { --accent: oklch(0.55 0.18 255); } /* darker tone on light bg */
[data-theme="dark"] { --accent: oklch(0.72 0.14 255); } /* lighter, less chroma */
Verificar nuevamente el contraste para el tema oscuro
Una paleta que cumple con los requisitos de contraste en el modo claro no dice nada sobre el modo oscuro. La fórmula de luminancia relativa de WCAG 2.x es lo suficientemente breve como para incluirla en un script:
const lum = ([r, g, b]) => {
const f = v => (v /= 255) <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
};
La relación de contraste divide entonces la luminosidad más clara entre la más oscura, con un desfase de 0.05 en cada caso:
const contrast = (a, b) => {
const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
};
Ejecutar esta prueba en cada color utilizado para leer texto permite detectar con fiabilidad los problemas que se pasan por alto a simple vista. Considere un tema de color papel cálido en el que todo parecía correcto en la pantalla: un ámbar tenía un ratio de 2.06:1, un rojo utilizado para resaltar palabras clave en la sintaxis tenía 2.80:1, y un dorado usado para números tenía 2.50:1. Los tres fueron aprobados visualmente, pero ninguno cumple con los requisitos de AA.
La fórmula de WCAG 2.x tiene un punto ciego conocido: trata de manera idéntica los colores claros sobre fondo oscuro y viceversa, por lo que una paleta oscura puede obtener buenas calificaciones pero seguir resultando deslumbrante. Esa debilidad es la razón por la que se desarrolló APCA, pero APCA no forma parte de WCAG 2.2 y aún no es normativa en ningún lugar.
Qué dicen las investigaciones sobre el modo oscuro y la legibilidad
A menudo este punto se expone de forma incorrecta. El resumen de investigaciones del Nielsen Norman Group indica:
- En personas con visión normal, el modo claro mostró mejores resultados en todas las mediciones según los estudios de Piepenbrock et al. (2013, Ergonomics) y Dobres et al. (2017, Applied Ergonomics). La ventaja aumenta a medida que disminuye el tamaño de la fuente. La explicación es óptica: el texto oscuro sobre un fondo claro genera más luz, lo que hace que la pupila se contraiga; una pupila más pequeña implica menos aberraciones esféricas y una mayor profundidad de campo.
- En personas con visión reducida, Legge et al. (1985, Vision Research) descubrieron que los siete participantes con medios oculares nublados, especialmente cataratas, leían más rápido en el modo oscuro.
- A largo plazo, Aleman et al. (2018, Scientific Reports) sugieren que la exposición continua al modo claro podría estar relacionada con la miopía debido al adelgazamiento de la coroides.
- La recomendación práctica es permitir que los usuarios cambien al modo oscuro si así lo desean.
La interpretación honesta es que el modo oscuro sirve a las preferencias personales y a ciertas necesidades de accesibilidad; no se ha demostrado que mejore la legibilidad para el público en general. Ese es el argumento más sólido a favor de un control con tres estados en lugar de ofrecer el modo oscuro como predeterminado.
Puntos clave
- Trata el modo oscuro como tres problemas: elegir el tema, cambiar los colores y aplicar la elección antes de la primera pintura.
- Nombra los tokens según sus funciones, úsalos en un único bloque
data-themey manténcolor-schemeen la raíz sincronizado con él, respaldado por la etiqueta meta. - Decide el tema antes de la primera pintura, ya sea con una cookie leída por el servidor o con un script en línea que bloquee otras acciones, y acepta el compromiso de seguridad que implica dicho script.
- Ofrece los estados claro, oscuro y sistema como opciones, y solo sigue los cambios del sistema operativo cuando se selecciona este último.
prefers-color-scheme en lugar de una capa de tokens es la solución más rápida y sencilla.filter: invert() en una página que controle: convierte la raíz en el bloque contenedor de cada elemento fijo.Lecturas relacionadas
- Longitud de líneas, escalas de espaciado, superficies oscuras, sombras y anillos de enfoque en CSS — Aprenda los fundamentos de CSS detrás de interfaces pulidas: longitudes de líneas basadas en ch, una escala de espaciado de 4px, superficies oscuras en capas, sombras multicapa y anillos visibles al enfocar.
- Timeres de cuenta regresiva sin desviaciones en React: De setTimeout a CSS puro — Compare setTimeout, requestAnimationFrame y una técnica de CSS sin JavaScript para cuentas regresivas en React, incluyendo el truco del retraso único que mantiene los dígitos sincronizados.
- Distribuyendo CSS moderno de forma segura: Anclajes, carriles de rejilla, alcance y temas luz-oscuridad() — Aprenda cómo el posicionamiento de anclajes, los popovers, la disposición en carriles de rejilla, las transiciones de vista, los temas luz-oscuridad() y @scope reemplazan a las bibliotecas de JavaScript, y cómo adoptar cada uno con soluciones alternativas.
- De dónde proceden los valores CSS cuando se setea None: cómo funciona la herencia — Aprenda cómo deciden los navegadores si una propiedad hereda, por qué los hijos reciben el valor calculado del padre, y cómo inherit e initial le brindan un control explícito.