Infoxicator.com

Dark Mode No es Suficiente! Esta es una alternativa…

Published

En estos días, la mayoría de los sitios web tienen una opción para seleccionar el “modo oscuro”, y si encuentras uno sitio web sin él, te dan ganas de gritar: “¡Cómo te atreves a quemar mis retinas!”. Pero, ¿qué pasa si quisiera algo más que un esquema de color claro y oscuro y tuvieras la opción de usar “Modo gris”, o “Modo Navidad” o “Mi modo de película / videojuego favorito”?

Creando un selector de temas con React

100543720-ad835980-3249-11eb-826f-296b559f1837.gif (1307×431)

Estas son las características que estoy buscando:

  • Cambiar entre un número infinito de temas.
  • El valor del tema actual debería estar disponible para todos los componentes en la aplicación.
  • Modos “light” y “dark” predeterminados según el sistema operativo del usuario o la preferencia del navegador.
  • El tema elegido debe conservarse en el navegador del usuario.
  • Sin “Flash of Death” cuando se refresca el navegador.

Para este tutorial, usaré Next.js, pero si está usando Gatsby, hay un regalo al final del artículo 😉

Comencemos con la plantilla de blog de Next.js estándar que viene con Tailwind incluido, sin embargo, esta solución debería funcionar con cualquier libreria de su elección, incluido styled-components y CSS Modules.

npx create-next-app --example blog-starter blog-starter-app 

Agregar colores del tema

Usaremos variables CSS para agregar colores a nuestro sitio y una clase CSS global para configurar nuestro tema.

Abra su archivo index.css y agregue una nueva clase para cada tema que desee agregar, por ejemplo:

.theme-twitter {
    --color-bg-primary: #15202B;
    --color-bg-primary-light: #172D3F;
    --color-bg-accent: #1B91DA; 
    --color-bg-accent-light: #1B91DA; 
    --color-bg-secondary: #657786;
    --color-text-link: #1B91DA;    
    --color-bg-compliment: #112b48;
    --color-bg-default: #192734;
    --color-bg-inverse: #1B91DA;
    --color-text-primary: #fff;
    --color-text-secondary: #f2f2f2;
    --color-text-default: #e9e9e9;
    --color-text-default-soft: #6a6a6a;
    --color-text-inverse: #1B91DA;
    --color-text-inverse-soft: #1B91DA;
  }

.theme-midnightgreen {
  --color-bg-primary: #004953;
  --color-bg-primary-light: #E7FDFF;
  --color-bg-accent: #DE7421; 
  --color-bg-accent-light: #DE7421; 
  --color-bg-secondary: #E7FDFF;
  --color-text-link: #008ca0;
  --color-bg-compliment: #f5f5ff;
  --color-bg-default: #f5f5f5;
  --color-bg-inverse: #d77d4d;
  --color-text-primary: #f5f5f5;
  --color-text-secondary: #004953;
  --color-text-default: #303030;
  --color-text-default-soft: #484848;
  --color-text-inverse: #008ca0;
  --color-text-inverse-soft: #ffffffb3;
}

.theme-my-favourite-colors {
 ...
}

Abra su archivo tailwind.config.js y agregue las clases con las variables CSS que creó en el paso anterior. Ejemplo:

module.exports = {
  purge: ['./components/**/*.js', './pages/**/*.js'],
  theme: {
    extend: {
      colors: {
        'accent-1': 'var(--color-bg-primary)',
        'accent-2': 'var(--color-bg-secondary)',
        'accent-7': 'var(--color-bg-accent)',
        success: '#0070f3',
        cyan: '#79FFE1',
      },
      textColor: {
        white: "var(--color-text-primary)",
        grey: "var(--color-text-link)",
        black: "var(--color-text-secondary)",
      },
    },
  },
}

Nota: Si no está usando Tailwind, puede configurar su solución usando las mismas variables CSS, el resto de los pasos de este tutorial deberían ser los mismos.

Asigne la clase CSS a la etiqueta del body del documento para aplicar sus estilos personalizados. Abra el archivo _document.js y agregue su tema predeterminado por ahora

<body className="theme-twitter">
  <Main />
  <NextScript />
</body>

Actualice la página y debería ver los colores del tema para la clase que ha seleccionado.

Estado del Tema

Para manejar el estado, haremos que el tema esté disponible globalmente para todos nuestros componentes y cambie entre diferentes temas; vamos a utilizar la API React Context para crear un contexto y un proveedor de tema.

Crea un nuevo archivo en context/theme-context.js

import React from "react";
import useLocalStorage from "./context/use-local-storage";

const ThemeContext = React.createContext();

export const ThemeProvider = ({ children }) => {
  const [theme, setTheme] = useLocalStorage("theme", null);
  const switchTheme = (newTheme) => {
    // eslint-disable-next-line no-undef
    const root = document.body;
    root.classList.remove(theme);
    root.classList.add(newTheme);
    setTheme(newTheme);
  };
  return (
    <ThemeContext.Provider value={{ theme, switchTheme }}>
      {children}
    </ThemeContext.Provider>
  );
};

export default ThemeContext;

Estoy usando el hook useLocalStorage para conservar el valor del tema bajo la clave “theme”. El código fuente de este hook se puede encontrar aquí: https://github.com/infoxicator/use-theme-switcher/blob/master/src/use-local-storage.js

El valor inicial será nulo si el localStorage está vacío, más sobre esto más adelante.

El hook switchTheme reemplazará el valor de la clase CSS que agregamos al body con el nuevo valor pasado a esta función, además de conservar el valor en el localStorage.

Agrega el nuevo provedor en _app.js

import '../styles/index.css'
import { ThemeProvider } from '../context/theme-context';

export default function  MyApp({ Component, pageProps }) {
  return <ThemeProvider>
      <Component {...pageProps} />
    </ThemeProvider>
}

El Selector De Tema

Creemos un componente selector de temas básico que alternará entre los temas disponibles.

import React from "react";

const myThemes = [
    {
        id: "theme-midnightgreen",
        name: "Midnight Green",
    },
    {
        id: "theme-spacegray",
        name: "Space Gray",
    },
    {
        id: "theme-twitter",
        name: "Twitter Dark",
    }
]

const ThemePicker = ({ theme, setTheme }) => {
    if (theme) {
        return (
            <div>
            {myThemes.map((item, index) => {
                const nextTheme = myThemes.length -1 === index ? myThemes[0].id : myThemes[index+1].id;
                
                return item.id === theme ? (
                    <div key={item.id} className={item.id}>
                    <button
                        aria-label={`Theme ${item.name}`}
                        onClick={() => setTheme(nextTheme)}
                    >
                        {item.name}
                    </button>
                    </div>
                ) : null;
                    }
                )}
            </div>
        );
    }
    return null;
};

export default ThemePicker;

Este componente tomará una colección de temas disponibles y generará un botón que selecciona el siguiente tema disponible al hacer clic. Esta es una implementación muy básica de este componente, pero mas adelante usted podría agregar su lógica y diseño personalizados, como un menú desplegable o una lista, etc.

Renderice el componente ThemeSwitcher en la parte superior del sitio. Abra layout.js y agregue lo siguiente:

import ThemePicker from './theme-picker';
import React, { useContext } from "react"
import ThemeContext from '../context/theme-context';

export default function Layout({ preview, children }) {
  const { theme, switchTheme } = useContext(ThemeContext);
  return (
    <>
      <Meta />
      <div className="min-h-screen bg-accent-1 text-white">
        <Alert preview={preview} />
        <ThemePicker theme={theme ? theme : 'theme-midnightgreen'} setTheme={switchTheme} />
        <main>{children}</main>
      </div>
      <Footer />
    </>
  )
}

El valor del tema es nulo por primera vez y cuando el usuario aún no ha seleccionado un tema personalizado, por esa razón, estamos pasando el valor del tema predeterminado al componente ThemePicker.

Superando el “Destello Blanco de la Muerte”

¿Quién hubiera pensado que un simple error como este sería tan complejo y tan profundamente conectado con las diferentes formas de renderizar sitios web (renderizado en el servidor (SSR), generación de sitios estáticos (SSG), renderizado en el navegador (client side rendering)? En pocas palabras, el “destello” es causado por el orden en que se procesa el HTML inicial. Cuando usamos SSR o SSG con herramientas como next.js o gatsby, el HTML se procesa antes de que llegue al cliente, por lo que el valor del tema inicial que proviene del localStorage será diferente del valor que se generó en el servidor. produciendo un pequeño “flash” mientras se aplica el tema correcto.

La clave para solucionar este problema es utilizar un script de “bloqueo de procesamiento” que selecciona la clase CSS correcta antes de que el contenido del sitio se procese en el DOM.

Cree un archivo nuevo llamado theme-script.js

import React from "react";

function setColorsByTheme(
  defaultDarkTheme,
  defaultLightTheme,
  themeStorageKey
) {
  var mql = window.matchMedia("(prefers-color-scheme: dark)");
  var prefersDarkFromMQ = mql.matches;
  var persistedPreference = localStorage.getItem(themeStorageKey);
  var root = document.body;
  var colorMode = "";

  var hasUsedToggle = typeof persistedPreference === "string";

  if (hasUsedToggle) {
    colorMode = JSON.parse(persistedPreference);
  } else {
    colorMode = prefersDarkFromMQ ? defaultDarkTheme : defaultLightTheme;
    localStorage.setItem(themeStorageKey, JSON.stringify(colorMode));
  }

  root.classList.add(colorMode);
}

const ThemeScriptTag = () => {
  const themeScript = `(${setColorsByTheme})(
        'theme-twitter',
        'theme-midnightgreen',
        'theme',
      )`;
// eslint-disable-next-line react/no-danger
  return <script dangerouslySetInnerHTML={{ __html: themeScript }} />;
};

export default ThemeScriptTag;

Este script debe renderizarse antes que el resto del contenido y “bloquear” la renderización de la aplicación hasta que se determine el valor del tema. Agréguelo a su _document.js

import Document, { Html, Head, Main, NextScript } from 'next/document'
import ThemeScriptTag from '../components/theme-script';

export default class MyDocument extends Document {
  render() {
    return (
      <Html lang="en">
        <Head>
        </Head>
        <body>
          <ThemeScriptTag />
          <Main />
          <NextScript />
        </body>
      </Html>
    )
  }
}

Si desea profundizar en este problema y esta solución, Josh W. Comau creó una publicación en su blog que analiza este problema paso a paso y ofrece esta solución.

Recursos Adicionales

NPM Package

He creado un paquete npm que envuelve los componentes creados en este tutorial para que sea más fácil de agregar a su sitio Next.js:

https://www.npmjs.com/package/use-theme-switcher

Gatsby Plugin

Gatsby y su excelente sistema de plugins hacen que sea muy fácil agregar este selector de temas instalando:

https://www.npmjs.com/package/gatsby-plugin-theme-switcher

Conclusion

Ahora puede recompensar a sus usuarios con nuevos temas personalizables para su sitio, incluso podría crear componentes realmente creativos para seleccionar el tema.


Recent Posts

How I translated my Next.js Blog

English is my second language and before I was proficient, I always found it really difficult to find tech resources in my own language. That’s why I decided to translate my blog and make all my content available in Spanish. Internationalization with Next.js Next.js has made Internationalization (i18n) a breeze with one of the most[…]

Rules of Micro-Frontends

This is an opinionated list of best practices when designing applications that follow the Micro-frontend pattern

Dark Mode Is Not Enough! Here is an alternative…

These days most websites have an option to toggle Dark mode, but what if I wanted more?