Cómo implementar modo Light y Dark (Day/Night) en Next.js con Tailwind CSS v4

En este tutorial vas a implementar un modo light/dark real en Next.js con App Router, tomando como base una estructura funcional de proyecto con next-themes, Tailwind CSS v4 y un switch accesible.

Si ya tienes un proyecto en producción, este enfoque te permite añadir modo Day/Night sin reescribir todo tu diseño: solo necesitas centralizar colores en variables y aplicar variantes dark: donde tenga sentido visual.

Índice del contenido

¿Por qué vale la pena implementar modo Day/Night?

Más allá de verse moderno, el cambio de tema mejora la experiencia de lectura en diferentes contextos: oficinas con alta luz ambiental, uso nocturno, pantallas OLED y preferencias de accesibilidad.

En un blog técnico, un buen modo oscuro puede reducir la fatiga visual cuando los usuarios pasan varios minutos revisando snippets, diagramas o documentación extensa.

  • Mejor UX: cada usuario elige cómo consumir tu contenido.
  • Consistencia: la identidad visual se mantiene en ambos temas.
  • Escalabilidad: con variables CSS, ajustar paleta es más simple.
  • Compatibilidad: funciona bien con App Router y SSR/CSR híbrido.

Arquitectura usada en el repositorio base

El repositorio usa una separación simple y limpia: estilos globales en app/globals.css, proveedor de tema en app/providers/theme-provider.tsx, y el switch como componente cliente en app/components/ThemeSwitch.tsx.

Esta organización evita lógica de tema dispersa y facilita reutilizar el toggle en headers, sidebars o cualquier layout del proyecto.

Configurar Tailwind v4 para detectar la clase .dark

En Tailwind v4 el modo oscuro se define en CSS. El patrón usado es declarar una variante personalizada dark con @custom-variant y mantener tokens de color con variables CSS.

El objetivo de esta capa base es que tus componentes no tengan que conocer detalles internos del tema. Solo consumen utilidades y tokens, mientras la clase .dark actúa como interruptor global.

/* app/globals.css */
@import "tailwindcss";

@custom-variant dark (&:is(.dark *));

:root {
  --background: #ffffff;
  --foreground: #171717;
}

@theme inline {
  --color-background: var(--background);
  --color-foreground: var(--foreground);
}

@media (prefers-color-scheme: dark) {
  :root {
    --background: #0a0a0a;
    --foreground: #ededed;
  }
}

body {
  background: var(--background);
  color: var(--foreground);
}

Con esto, cualquier utilidad dark: se activará cuando exista la clase dark en el árbol del documento.

Idea clave: si defines variables como --background y --foreground en una sola fuente, el mantenimiento del diseño se vuelve mucho más predecible que manejar colores sueltos en cada componente.

Instalar next-themes

Antes de crear el provider y el switch, instala la dependencia que se encarga de guardar la preferencia del usuario y sincronizarla con la clase dark en el documento.

En un proyecto Next.js con npm, el comando es muy simple y basta con ejecutarlo una sola vez:

npm install next-themes

Si usas pnpm, yarn o bun, el paquete es el mismo; solo cambia el gestor de paquetes que utilices en tu proyecto.

ThemeProvider con next-themes (patrón App Router)

El proveedor se mantiene minimalista y delega opciones al layout principal. Esto hace más fácil ajustar comportamiento sin tocar el wrapper.

Este patrón es especialmente útil cuando tu app crece: puedes añadir páginas privadas, dashboard o landing sin duplicar lógica de tema por cada ruta.

// app/providers/theme-provider.tsx
"use client";

import { ThemeProvider as NextThemesProvider } from "next-themes";
import type { ThemeProviderProps } from "next-themes";

export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
  return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}
// app/layout.tsx
import "./globals.css";
import { ThemeProvider } from "./providers/theme-provider";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="es" suppressHydrationWarning>
      <body>
        <ThemeProvider
          attribute="class"
          defaultTheme="system"
          enableSystem
          disableTransitionOnChange
        >
          {children}
        </ThemeProvider>
      </body>
    </html>
  );
}

suppressHydrationWarning y disableTransitionOnChange ayudan a evitar parpadeos y saltos visuales al hidratar cuando el tema inicial cambia entre servidor y cliente.

Hydration estable

suppressHydrationWarning evita advertencias visuales cuando el SSR y el cliente no coinciden en el primer render del tema.

Transición suave

disableTransitionOnChange reduce flashes molestos al alternar entre light y dark, sobre todo en layouts con muchos bloques.

Crear un ThemeSwitch reutilizable

El switch consulta resolvedTheme para saber el estado actual y usa una bandera mounted para render seguro en cliente, evitando desajustes de hidratación.

Además, aceptar un className opcional permite integrarlo sin fricción en distintos contextos: navbar principal, panel lateral, cards de preferencias o cabeceras móviles.

// app/components/ThemeSwitch.tsx
"use client";

import { useEffect, useState } from "react";
import { useTheme } from "next-themes";
import { FiMoon, FiSun } from "react-icons/fi";

export function ThemeSwitch({ className }: { className?: string }) {
  const { resolvedTheme, setTheme } = useTheme();
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    setMounted(true);
  }, []);

  if (!mounted) {
    return (
      <button
        aria-label="Cargando tema"
        className={
          "inline-flex h-9 w-9 items-center justify-center rounded-full border border-gray-300 bg-white dark:bg-gray-800 " +
          (className ?? "")
        }
      >
        <span className="animate-pulse text-xs">···</span>
      </button>
    );
  }

  const isDark = resolvedTheme === "dark";

  return (
    <button
      onClick={() => setTheme(isDark ? "light" : "dark")}
      aria-label={isDark ? "Cambiar a modo claro" : "Cambiar a modo oscuro"}
      className={
        "inline-flex h-9 w-9 items-center justify-center rounded-full border border-gray-300 bg-white text-yellow-500 shadow-sm transition hover:-translate-y-0.5 hover:shadow-md dark:border-gray-600 dark:bg-gray-800 dark:text-yellow-300 " +
        (className ?? "")
      }
    >
      {isDark ? <FiSun size={18} /> : <FiMoon size={18} />}
    </button>
  );
}

Integrarlo en una página Next.js

Ya con el provider activo, el switch puede colocarse en cualquier parte de la UI. Este ejemplo mantiene el estilo del repositorio, usando utilidades con variantes dark: para fondo, texto y bordes.

Si quieres convertir esto en un patrón de diseño completo, puedes extraer una pequeña capa de componentes (Header, Surface, SectionTitle) que ya vengan preparados para ambos modos.

// app/page.tsx
import { ThemeSwitch } from "./components/ThemeSwitch";

export default function Home() {
  return (
    <div className="relative flex min-h-screen items-center justify-center bg-zinc-50 dark:bg-black">
      <main className="flex w-full max-w-3xl flex-col gap-6 bg-white px-6 py-16 dark:bg-black sm:px-12">
        <div className="flex items-center justify-between">
          <h1 className="text-xl font-semibold text-black dark:text-zinc-50">
            Light/Dark Mode con Next.js + Tailwind v4
          </h1>
          <ThemeSwitch />
        </div>

        <p className="text-zinc-600 dark:text-zinc-400">
          Cambia entre modo claro y oscuro respetando la preferencia del sistema y la selección del usuario.
        </p>
      </main>
    </div>
  );
}

Buenas prácticas recomendadas

  • Define @custom-variant dark una sola vez en globals.css.
  • Usa defaultTheme="system" y enableSystem para una primera carga coherente.
  • Activa disableTransitionOnChange para evitar flicker visual al alternar tema.
  • Haz que tu ThemeSwitch acepte className para reutilizarlo en distintos layouts.

Errores comunes al implementar dark mode

  • Olvidar el attribute="class" en el provider.
  • Usar clases dark: sin definir la variante en Tailwind v4.
  • No contemplar estado mounted en el switch de tema.
  • Aplicar transiciones agresivas en todo el layout.
  • No probar contraste real de texto en dark mode.
  • Mezclar colores hardcodeados con variables sin estrategia.

FAQ

¿Necesito tailwind.config.js para dark mode en v4?

No necesariamente. En Tailwind v4 puedes definir el comportamiento con @custom-variant dark dentro de globals.css.

¿Por qué usar resolvedTheme en lugar de theme?

Porque resolvedTheme te da el resultado final real (light/dark) incluso cuando el valor base es system.

¿Se puede guardar el tema por usuario autenticado?

Sí. Puedes sincronizar la preferencia de next-themes con tu backend y restaurarla al iniciar sesión.

¿Qué pasa si no uso mounted en el ThemeSwitch?

Es común ver hydration mismatch o cambios visuales bruscos en el primer render cliente-servidor.

Conclusión

Con esta base tienes una implementación profesional, escalable y lista para proyectos reales. El flujo es claro: Tailwind v4 define la variante, next-themes controla el estado y tu UI reacciona mediante utilidades dark:.

A partir de aquí puedes llevar el sistema más lejos: guardar preferencias por usuario autenticado, crear temas adicionales (sepia, high contrast) o sincronizar la paleta con branding dinámico para cada sección del blog.

Tip para branding personalizado

Si tu blog ya usa variables como --main-dark y --main-white, mapea esos tokens en @theme y sobreescribe sus valores bajo .dark. Así mantendrás coherencia visual en ambos modos sin duplicar estilos.

¿Quieres probarlo ahora mismo?

Puedes montar una demo local en minutos y usar este patrón como base para tu siguiente proyecto con Next.js y Tailwind v4.

Ver demo/código en GitHub

0/Deja un comentario/Comentarios

¡Hola! Nos alegra mucho que hayas llegado hasta aquí y que estés leyendo este artículo en Edeptec.
Este formulario es un espacio abierto para ti: puedes dejar un comentario con tus dudas, sugerencias, experiencias o simplemente tu opinión sobre el tema tratado.

» ¿Te resultó útil la información?
» ¿Tienes alguna experiencia personal que quieras compartir?
» ¿Se te ocurre algún tema que te gustaría ver en próximos artículos?

Recuerda que este espacio es para aprender y compartir, por eso te animamos a participar de manera respetuosa y constructiva. Tus comentarios pueden ayudar a otros lectores que están en el mismo camino, ya sea en electrónica, programación, deportes o tecnología.

¡Gracias por ser parte de esta comunidad de aprendizaje! Tu participación es lo que hace crecer este proyecto.