Tutoriales

Implementar el modo oscuro: guía completa para desarrolladores

9 min de lectura

El modo oscuro ha pasado de ser una preferencia de nicho a una funcionalidad esperada. Los usuarios en todas las plataformas — macOS, Windows, Android, iOS — pueden establecer una apariencia oscura a nivel de sistema, y esperan que los sitios web y las aplicaciones la respeten. Implementar el modo oscuro correctamente requiere más que intercambiar blanco por negro: exige un enfoque sistemático del color, el contraste y el control del usuario. Esta guía recorre el proceso completo, desde la arquitectura CSS hasta la mecánica del alternador con JavaScript y las pruebas exhaustivas de ambos temas.

Propiedades personalizadas de CSS para temas

La forma más mantenible de gestionar el modo oscuro en CSS es mediante propiedades personalizadas (también llamadas variables CSS). En lugar de dispersar valores de color por tus hojas de estilo, defines cada color como una variable en :root y luego redefines esas variables para el modo oscuro. Los estilos de los componentes referencian solo las variables — nunca códigos hex directos.

Definición de tus paletas clara y oscura

Comienza con una paleta de modo claro como valor predeterminado. Un buen punto de partida podría verse así:

:root {
  /* Fondos */
  --color-bg-base:      #FFFFFF;
  --color-bg-elevated:  #F8F9FA;
  --color-bg-overlay:   #F1F3F5;

  /* Texto */
  --color-text-primary:   #1A1A2E;
  --color-text-secondary: #4A4A6A;
  --color-text-muted:     #6C757D;

  /* Bordes */
  --color-border:         #DEE2E6;
  --color-border-strong:  #ADB5BD;

  /* Marca / acento */
  --color-accent:         #3B82F6;
  --color-accent-hover:   #2563EB;

  /* Retroalimentación */
  --color-success:  #22C55E;
  --color-warning:  #F59E0B;
  --color-danger:   #EF4444;
}

Luego define las anulaciones para el modo oscuro en un bloque separado. La clave es que no estás simplemente invirtiendo colores — estás eligiendo una paleta diferente, diseñada específicamente para una superficie oscura:

[data-theme="dark"] {
  /* Fondos */
  --color-bg-base:      #0F0F17;
  --color-bg-elevated:  #1A1A2E;
  --color-bg-overlay:   #252540;

  /* Texto */
  --color-text-primary:   #E8E8F0;
  --color-text-secondary: #A8A8C0;
  --color-text-muted:     #6A6A88;

  /* Bordes */
  --color-border:         #2E2E4A;
  --color-border-strong:  #4A4A6A;

  /* Marca / acento — a menudo ligeramente más claro para legibilidad sobre fondo oscuro */
  --color-accent:         #60A5FA;
  --color-accent-hover:   #93C5FD;

  /* Retroalimentación — ligeramente desaturado para evitar dureza */
  --color-success:  #4ADE80;
  --color-warning:  #FCD34D;
  --color-danger:   #F87171;
}

Observa que el acento #3B82F6 en modo claro se convierte en #60A5FA en modo oscuro. El tono es el mismo pero la luminosidad aumenta — esto es necesario porque el contexto de contraste ha cambiado. Un color que cumple con WCAG AA contra un fondo blanco casi siempre fallará contra un fondo casi negro a menos que lo ajustes. El Generador de tonos te permite explorar el rango completo 50–950 de cualquier color, facilitando la elección del tono apropiado para cada tema.

Uso de variables en los componentes

Con la paleta establecida, cada componente referencia variables en lugar de valores directos:

.card {
  background-color: var(--color-bg-elevated);
  border: 1px solid var(--color-border);
  color: var(--color-text-primary);
}

.btn-primary {
  background-color: var(--color-accent);
  color: #FFFFFF;
}

.btn-primary:hover {
  background-color: var(--color-accent-hover);
}

Cuando el atributo [data-theme="dark"] está presente en el elemento <html>, todas las variables se actualizan simultáneamente, y cada componente que las referencia cambia de apariencia — sin necesidad de CSS adicional.

La media query prefers-color-scheme

Antes de que el usuario interactúe con un alternador, puedes respetar su preferencia de sistema operativo usando la media query prefers-color-scheme. Esta media query se activa cuando el sistema operativo está configurado con apariencia oscura.

@media (prefers-color-scheme: dark) {
  :root {
    --color-bg-base:      #0F0F17;
    --color-bg-elevated:  #1A1A2E;
    --color-bg-overlay:   #252540;
    --color-text-primary:   #E8E8F0;
    --color-text-secondary: #A8A8C0;
    --color-text-muted:     #6A6A88;
    --color-border:         #2E2E4A;
    --color-border-strong:  #4A4A6A;
    --color-accent:         #60A5FA;
    --color-accent-hover:   #93C5FD;
    --color-success:  #4ADE80;
    --color-warning:  #FCD34D;
    --color-danger:   #F87171;
  }
}

Este enfoque funciona sin JavaScript, no genera desplazamiento de diseño, y respeta la preferencia declarada por el usuario inmediatamente al cargar la página. Es la línea base correcta. La limitación es que los usuarios no pueden anularla en tu aplicación — si el sistema operativo está en oscuro, el sitio estará en oscuro, sin escape. Por eso la mayoría de las implementaciones en producción añaden un alternador con JavaScript encima de la media query.

Combinación de ambos enfoques

El patrón recomendado usa la media query como predeterminado y el atributo data-theme como anulación explícita. Puedes manejar esto con un truco de especificidad CSS u ordenando tus reglas correctamente:

/* 1. Predeterminado en modo claro */
:root {
  --color-bg-base: #FFFFFF;
  /* ... */
}

/* 2. Anulación de modo oscuro del sistema (cuando no hay preferencia explícita) */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --color-bg-base: #0F0F17;
    /* ... */
  }
}

/* 3. Modo oscuro explícito (el usuario alternó con JS) */
[data-theme="dark"] {
  --color-bg-base: #0F0F17;
  /* ... */
}

El selector :not([data-theme="light"]) en la media query significa que la preferencia oscura del sistema operativo solo se aplica cuando el usuario no ha elegido explícitamente el modo claro. Una vez que alternan, su elección explícita prevalece.

Mecanismo de alternancia con JavaScript

Un alternador bien implementado hace tres cosas: cambia la apariencia actual de inmediato, persiste la preferencia en localStorage, y lee la preferencia guardada al cargar la página antes del primer renderizado.

Leer la preferencia al cargar

Este script debe ejecutarse en <head> — antes de que la página se renderice — para evitar un destello del tema incorrecto:

<head>
  <script>
    (function() {
      const stored = localStorage.getItem('theme');
      const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
      const theme = stored ?? (prefersDark ? 'dark' : 'light');
      document.documentElement.setAttribute('data-theme', theme);
    })();
  </script>
</head>

Esto establece inmediatamente data-theme en <html> antes de que se apliquen estilos. El navegador calcula los valores correctos de las propiedades personalizadas desde el primer renderizado — sin destellos.

La función de alternancia

function toggleTheme() {
  const current = document.documentElement.getAttribute('data-theme');
  const next = current === 'dark' ? 'light' : 'dark';
  document.documentElement.setAttribute('data-theme', next);
  localStorage.setItem('theme', next);
}

// Conectarlo a un botón
document.getElementById('theme-toggle').addEventListener('click', toggleTheme);

Sincronización del estado del botón de alternancia

El botón de alternancia debe reflejar visualmente el modo actual. Un enfoque simple usa iconos:

<button id="theme-toggle" aria-label="Toggle dark mode">
  <span class="icon-light">☀️</span>
  <span class="icon-dark">🌙</span>
</button>
[data-theme="dark"] .icon-light { display: none; }
[data-theme="dark"] .icon-dark  { display: inline; }
[data-theme="light"] .icon-light { display: inline; }
[data-theme="light"] .icon-dark  { display: none; }

Dado que la visibilidad del ícono está controlada por variables CSS vinculadas a data-theme, el estado del botón se actualiza automáticamente cada vez que el atributo cambia — no se necesita JavaScript adicional.

Estrategias de adaptación de color

Elegir los colores del modo oscuro no es tan simple como invertir tu paleta de modo claro. Varios principios guían las buenas elecciones de color oscuro.

Reducir el contraste, no solo invertirlo

El texto blanco puro sobre fondo negro puro (#FFFFFF sobre #000000) es técnicamente el contraste máximo — 21:1 — pero es cognitivamente agotador para una lectura prolongada. Reduce ambos extremos: usa un blanco roto como #E8E8F0 para el texto del cuerpo y un azul marino muy oscuro como #0F0F17 para el fondo de la página. Esto preserva un contraste amplio (aún por encima de 15:1) mientras reduce la fatiga visual.

Usa el Verificador de contraste para verificar que cada combinación de texto/fondo en tu tema oscuro cumpla al menos con WCAG AA (4.5:1 para texto normal, 3:1 para texto grande). Los puntos de fallo comunes incluyen:

  • Texto de marcador de posición en campos de formulario
  • Etiquetas de botones deshabilitados
  • Texto de metadatos secundario (marcas de tiempo, firmas)
  • Botones de solo icono sin etiquetas visibles

Elevación en capas con superficies oscuras

En modo claro, la elevación generalmente se expresa mediante sombras paralelas. En modo oscuro, las sombras se vuelven invisibles contra fondos oscuros. La especificación de Material Design 3 introdujo un enfoque más efectivo: las superficies más claras se sienten más altas. Usa fondos ligeramente más claros para componentes elevados:

/* Escala de elevación en modo oscuro */
--color-bg-base:     #0F0F17;  /* Fondo de página */
--color-bg-elevated: #1A1A2E;  /* Tarjetas, barras laterales */
--color-bg-overlay:  #252540;  /* Modales, menús desplegables */
--color-bg-tooltip:  #2E2E4A;  /* Tooltips */

#0F0F17 como base, #1A1A2E para tarjetas, #252540 para modales — cada paso es aproximadamente un 8–10 % más claro en términos de luminosidad HSL. Esto crea una jerarquía visual clara sin depender de sombras.

Desaturar ligeramente los colores del modo oscuro

Los colores muy saturados lucen duros y con aspecto de neón sobre fondos oscuros. Al adaptar los colores de tu marca para el modo oscuro, reduce la saturación entre un 10 y un 20 % además de aumentar la luminosidad. En lugar del vívido verde de éxito #22C55E, prefiere #4ADE80 — más claro y ligeramente menos saturado, que se lee como éxito sin fatiga visual.

El Generador de tonos es ideal aquí: ingresa el verde o azul primario de tu marca y explora el rango 300–400 para usos de texto e iconos en modo oscuro, versus el rango 500–600 para elementos interactivos.

Imágenes y medios

Las imágenes con fondo blanco lucen discordantes en modo oscuro. El CSS puede ayudar:

/* Reducir la dureza de las imágenes en modo oscuro */
[data-theme="dark"] img:not([src*=".svg"]) {
  filter: brightness(0.9) contrast(1.05);
}

/* O permitir que las imágenes se mezclen ligeramente con el fondo */
[data-theme="dark"] img {
  mix-blend-mode: luminosity;
  opacity: 0.9;
}

Para iconos SVG que necesitan adaptarse, usar currentColor como valor de relleno significa que adoptan automáticamente el color de texto actual:

.icon { color: var(--color-text-secondary); }
<svg fill="currentColor" viewBox="0 0 24 24">...</svg>

Prueba de ambos modos

Las pruebas exhaustivas evitan que las regresiones del modo oscuro lleguen a producción.

Emulación en DevTools del navegador

Chrome y Firefox ofrecen emulación del modo oscuro en DevTools sin cambiar la configuración del sistema operativo. En Chrome: abre DevTools, haz clic en el menú de tres puntos, ve a Más herramientas → Rendering, y establece "Emulate CSS media feature prefers-color-scheme" en "dark." Esto permite comparar ambos modos uno al lado del otro.

Pruebas de contraste automatizadas

La verificación manual puntual es propensa a errores. Integra la auditoría de contraste automatizada en tu flujo de trabajo de desarrollo. Usa herramientas como Axe o Lighthouse en CI para detectar nuevas adiciones de color que no cumplan los umbrales de las WCAG. El Verificador de contraste permite verificar rápidamente un par de primer plano/fondo contra todos los niveles de las WCAG — pega cualquier par de hex y obtén la relación al instante.

Prueba con contenido real

Los errores del modo oscuro suelen aparecer en páginas con contenido dinámico: imágenes subidas por usuarios, incrustados de terceros, gráficos y mapas. Prueba con una muestra de contenido realista, no solo con la biblioteca de componentes de tu sistema de diseño de forma aislada.

Pruebas a nivel del sistema operativo

Después de verificar mediante la emulación de DevTools, prueba con el sistema operativo realmente configurado en modo oscuro. La media query prefers-color-scheme se activa según la configuración del sistema operativo, y algunos navegadores se comportan ligeramente diferente dependiendo de si la configuración es real o emulada. Prueba también la transición: cambia de modo mientras una página está abierta y confirma que no ocurran desplazamientos de diseño ni artefactos de renderizado.

Lista de verificación de errores comunes

  • Valores hex codificados directamente en el CSS de componentes en lugar de variables — busca códigos hex directos en tus hojas de estilo y reemplázalos con variables
  • Iconos SVG con fill="#000000" codificado — cámbialo a fill="currentColor"
  • Componentes de terceros que no respetan data-theme — envuélvelos en una capa CSS con alcance
  • Propiedad color-scheme no configurada — agrega color-scheme: light dark a :root para que los elementos del navegador (barras de desplazamiento, controles de formularios) también se adapten
  • Falta <meta name="color-scheme"> en <head> — agrégalo para que el navegador pueda aplicar el color de fondo correcto antes de que se cargue el CSS
<meta name="color-scheme" content="light dark">
:root {
  color-scheme: light dark;
}

Esta pequeña adición hace que las barras de desplazamiento nativas, los selectores de fecha y otros controles de formulario renderizados por el sistema operativo cambien automáticamente a sus variantes oscuras — un detalle que muchas implementaciones pasan por alto.

Conclusiones clave

  • Define todos los colores como propiedades personalizadas de CSS en :root y anúlalas para el modo oscuro usando [data-theme="dark"]. Los estilos de los componentes referencian solo variables, haciendo que el cambio de tema sea sin esfuerzo una vez que la paleta está establecida.
  • Usa prefers-color-scheme: dark como predeterminado automático para usuarios que hayan configurado su sistema operativo con apariencia oscura. Añade encima un alternador con JavaScript con persistencia en localStorage para usuarios que quieran anularlo.
  • Ejecuta el script anti-destello en <head> antes de que se cargue el CSS para evitar el destello del tema incorrecto en el primer renderizado.
  • Los colores del modo oscuro no son colores del modo claro invertidos — reduce el contraste extremo, usa fondos más claros para transmitir elevación, y desatura ligeramente los acentos de marca para evitar la dureza de neón.
  • Verifica cada par de texto/fondo con el Verificador de contraste y usa el Generador de tonos para encontrar el tono correcto de cada color de marca para ambos temas.
  • Añade color-scheme: light dark y la etiqueta <meta> correspondiente para que los elementos de la interfaz de usuario nativos del navegador (barras de desplazamiento, entradas) también cambien automáticamente.

Colores relacionados

Marcas relacionadas

Herramientas relacionadas