Volver al Blog
Desarrollo28 de julio de 20266 min de lectura

Dark mode sin un solo if

Si tu modo oscuro es una cadena de ternarios, ya perdiste. Cómo un theme provider y tokens semánticos hacen que el tema sea casi gratis y el if (isDark) desaparezca del código.

IM
Ignacio MelendezDesarrollador Full-Stack y de Videojuegos
Dark mode sin un solo if

Casi todos los dark modes empiezan igual: un useState, un booleano isDark, y un ternario. Al principio parece inofensivo. Un componente aquí, otro allá. Seis meses después el proyecto tiene isDark importado en cuarenta ficheros, ternarios que deciden colores en el JSX, y cada vez que un diseñador añade un tercer tema (el "modo alto contraste" que pidió soporte), toca abrir esos cuarenta ficheros.

El problema no es el dark mode. El problema es que se está tratando el tema como lógica de negocio cuando debería ser infraestructura. Este artículo trata de cómo mover esa decisión a un único sitio y hacer que el if (isDark) desaparezca del código de los componentes.

1. El problema: if (isDark) esparcido por todo el código

Casi todos los desarrolladores, al enfrentarnos a una web o aplicación que necesita diferentes temas, escribimos por primera vez un patrón como este:

TSX
1function PriceTag({ amount, isDark }: PriceTagProps) {
2  return (
3    <span
4      style={{
5        color: isDark ? '#F5F5F5' : '#1A1A1A',
6        backgroundColor: isDark ? '#2A2A2A' : '#FFFFFF',
7        borderColor: isDark ? '#444444' : '#DDDDDD',
8      }}
9    >
10      {amount}11    </span>
12  )
13}

Funciona. El problema es lo que pasa cuando lo multiplicas. Cuando ya no es una sola pantalla sino 20 diferentes, con componentes nuevos, flujos diferentes, nuevos comportamientos... Cada componente que pinta algo tiene que:

  1. Recibir o leer isDark de algún sitio.
  2. Conocer los dos valores de color para cada propiedad.
  3. Elegir entre ellos con un ternario.

Tres cosas que deberían ser independientes pasan a estar acopladas: qué eres (un precio), qué rol visual tienes (texto principal sobre una superficie) y qué color concreto corresponde a ese rol en el tema activo. El componente no debería saber nada del tercer punto.

El síntoma más claro de que vas por mal camino es buscar isDark en el proyecto y encontrarlo en los componentes de UI. Si un botón sabe si hay modo oscuro, el tema ha dejado de ser infraestructura y se ha convertido en una dependencia que arrastras a todas partes.

2. El cambio de mentalidad: no preguntes por el tema, pregunta por el rol

La clave está en invertir la pregunta. En lugar de que el componente pregunte "¿estamos en modo oscuro?" y decida un color, el componente declara "yo soy texto principal sobre una superficie" y deja que el sistema resuelva el color.

Un color crudo como #1A1A1A no dice nada sobre su intención. text-primary sí: es el color del texto más importante, sea cual sea el tema. Ese nombre es un token semántico, y es la pieza que rompe la cadena de ternarios.

La diferencia es sutil pero lo cambia todo:

TSX
1// Antes: el componente decide el color
2color: isDark ? '#F5F5F5' : '#1A1A1A'
3
4// Después: el componente declara un rol, el tema decide
5color: 'var(--color-text-primary)'

En la segunda versión no hay condición. No hay booleano. El componente ni siquiera sabe cuántos temas existen. Podrías añadir un tercer tema mañana y este código no cambiaría.

Dos paneles de interfaz idénticos, uno en tema claro y otro en tema oscuro, conectados a un único interruptor central
Los dos temas comparten la misma estructura de componentes: lo único que cambia es el interruptor central que decide a qué primitivo apunta cada token semántico.

3. Tokens semánticos: surface, no gray-900

Aquí conviene distinguir dos capas de tokens, porque mezclarlas es un error común.

La primera capa son los tokens primitivos: la paleta cruda. gray-900, blue-500, red-600. Son valores fijos, no cambian entre temas. Son la caja de pinturas.

La segunda capa son los tokens semánticos: nombran una intención, no un color. surface, text-primary, border-subtle, accent. Estos sí cambian según el tema, y siempre apuntan a un token primitivo.

CSS
1:root {
2  /* Capa 1: primitivos (nunca cambian) */
3  --gray-50:  #F9FAFB;
4  --gray-100: #F3F4F6;
5  --gray-800: #1F2937;
6  --gray-900: #111827;
7  --indigo-400: #818CF8;
8  --indigo-500: #6366F1;
9
10  /* Capa 2: semánticos para el tema claro */
11  --color-surface:      var(--gray-50);
12  --color-text-primary: var(--gray-900);
13  --color-border-subtle: var(--gray-100);
14  --color-accent:       var(--indigo-500);
15}

La regla mental es simple: los componentes solo consumen tokens semánticos, nunca primitivos. Un componente que usa var(--gray-900) directamente vuelve a estar acoplado a un color concreto. Uno que usa var(--color-text-primary) es inmune al tema.

Nombra los tokens semánticos por su función, no por su aspecto. --color-text-primary sobrevive a un rediseño; --color-dark-gray-text no. Si mañana el texto principal pasa a ser azul marino, el nombre sigue siendo correcto.

4. El theme provider: un único punto que decide

Con los tokens semánticos definidos, cambiar de tema es redefinir a qué primitivo apunta cada semántico. Y eso ocurre en un solo sitio.

En CSS puro, un atributo en el <html> (o en body) basta:

CSS
1/* Tema claro: ya definido en :root arriba */
2
3[data-theme='dark'] {
4  --color-surface:       var(--gray-900);
5  --color-text-primary:  var(--gray-50);
6  --color-border-subtle: var(--gray-800);
7  --color-accent:        var(--indigo-400);
8}
9
10[data-theme='high-contrast'] {
11  --color-surface:       #000000;
12  --color-text-primary:  #FFFFFF;
13  --color-border-subtle: #FFFFFF;
14  --color-accent:        #FFFF00;
15}

Ahora, añadir un tercer tema (alto contraste) es tan fácil como añadir un bloque de CSS, sin cambios en componentes. Ese es el objetivo. El "theme provider" no es más que la pieza que pone el atributo data-theme correcto en el <html> y lo persiste.

La misma interfaz renderizada en tres temas distintos —claro, oscuro y uno cálido— cada uno alimentado por su propia paleta de tokens
Un tercer tema es solo una paleta más: la misma UI, los mismos componentes, cambiando únicamente a qué primitivos apuntan los tokens semánticos.
TSX
1type Theme = 'light' | 'dark' | 'high-contrast'
2
3const ThemeContext = createContext<{
4  theme: Theme
5  setTheme: (t: Theme) => void
6}>({ theme: 'light', setTheme: () => {} })
7
8export function ThemeProvider({ children }: { children: React.ReactNode }) {
9  const [theme, setTheme] = useState<Theme>(
10    () => (localStorage.getItem('theme') as Theme) ?? 'light'
11  )
12
13  useEffect(() => {
14    document.documentElement.setAttribute('data-theme', theme)
15    localStorage.setItem('theme', theme)
16  }, [theme])
17
18  return (
19    <ThemeContext.Provider value={{ theme, setTheme }}>
20      {children}
21    </ThemeContext.Provider>
22  )
23}
24
25export const useTheme = () => useContext(ThemeContext)
TSX
1const ORDER: Theme[] = ['light', 'dark', 'high-contrast']
2
3export function ThemeToggle() {
4  const { theme, setTheme } = useTheme()
5
6  const next = () => {
7    const i = ORDER.indexOf(theme)
8    setTheme(ORDER[(i + 1) % ORDER.length])
9  }
10
11  return (
12    <button onClick={next} aria-label="Cambiar tema">
13      Tema actual: {theme}
14    </button>
15  )
16}

El provider tiene exactamente una responsabilidad: mantener sincronizado el atributo data-theme con el estado del tema. No decide colores. No conoce componentes. Es un interruptor, no una tabla de decisiones.

Las CSS variables se resuelven en cascada, así que basta con ponerlas en el elemento raíz. Cualquier componente, por profundo que esté en el árbol, lee el valor del tema activo sin que nadie se lo pase por props ni por context. El navegador hace el trabajo de propagación por ti.

5. Consumir el tema sin ramas

Con la infraestructura montada, así se ve un componente que antes tenía tres ternarios:

TSX
1// Sin isDark, sin ternarios, sin conocer el tema
2function PriceTag({ amount }: { amount: number }) {
3  return (
4    <span
5      style={{
6        color: 'var(--color-text-primary)',
7        backgroundColor: 'var(--color-background)',
8        borderColor: 'var(--color-border-subtle)',
9      }}
10    >
11      {amount}12    </span>
13  )
14}

El componente perdió el prop isDark, perdió los ternarios, y ganó independencia total del tema. Si mañana hay cinco temas, este componente sigue exactamente igual.

Tailwind sigue misma la idea, solo que los tokens semánticos viven en la config y el runtime los mapea a las CSS variables:

TSX
1// tailwind.config.js mapea 'surface' -> var(--color-surface)
2function PriceTag({ amount }: { amount: number }) {
3  return (
4    <span className="text-text-primary bg-surface border-border-subtle border">
5      {amount}6    </span>
7  )
8}

Ni dark: ni variantes duplicadas por clase. Las utilidades apuntan a tokens semánticos y el tema se resuelve por debajo. La clave es que el nombre de la clase describe el rol (bg-surface), no el color.

Un buen sistema de temas no es el que soporta modo oscuro. Es el que hace que añadir el modo oscuro (o cualquier otro tema) no requiera tocar los componentes.

6. Casos límite: cuando de verdad se necesita saber el tema

Sería deshonesto decir que nunca vas a necesitar el tema en código. Hay casos legítimos, pero son pocos y muy concretos:

  • Imágenes o ilustraciones distintas por tema: a veces un logo necesita una versión clara y otra oscura. Aquí el token no basta porque cambia el asset, no un color.
  • Librerías de terceros que no leen los CSS variables: un mapa, un editor de código, un gráfico. Necesitan que les pasen una config de colores explícita.
  • prefers-color-scheme como valor inicial: respetar la preferencia del sistema operativo la primera vez que entra el usuario.

Para esos casos, el useTheme() sigue ahí. La diferencia es que ahora es la excepción documentada, no el patrón por defecto:

TSX
1function BrandLogo() {
2  const { theme } = useTheme()
3  const src = theme === 'light' ? '/logo-light.svg' : '/logo-dark.svg'
4  return <img src={src} alt="Logo" />
5}

Y para el valor inicial según el sistema, ni siquiera es necesario JS: una media query hace el trabajo antes de que cargue nada.

CSS
1@media (prefers-color-scheme: dark) {
2  :root:not([data-theme]) {
3    --color-surface:      var(--gray-900);
4    --color-text-primary: var(--gray-50);
5  }
6}

El peligro de tener useTheme() disponible es que vuelva a colarse en sitios donde no hace falta. La regla: si lo que cambia es un color, es un token; si lo que cambia es un asset o una config externa, entonces sí, hook se debe usar.

7. Conclusión: el tema como infraestructura, no como lógica

La diferencia entre un dark mode que da dolor y uno que no la marca dónde vive la decisión del color. Si esa decisión está repartida en ternarios por todos los componentes, cada tema nuevo es una cacería por el código. Si vive en una tabla de tokens semánticos que un provider resuelve en un único punto, añadir un tema es un bloque de CSS.

El camino es siempre el mismo: primitivos que nunca cambian, semánticos que nombran intención, componentes que solo consumen semánticos, y un provider que se limita a poner el atributo correcto en la raíz. Con eso, el if (isDark) no es que lo elimines a mano: es que nunca tienes una razón para escribirlo.

Y ese es el verdadero indicador de que lo has hecho bien. No que tu dark mode sea bonito, sino que puedas añadir el siguiente tema sin abrir un solo componente.