Modo oscuro en widgets embebidos: cómo seguir el selector de tema de tu app

widget

Lanzas el modo oscuro. El selector está en la cabecera, recuerda la elección y toda la app cambia sin un fallo. Entonces alguien abre el widget de feedback de la esquina, y sigue de un blanco cegador.

O, más sutil: está oscuro, pero solo porque su máquina lo está. El usuario que eligió el modo oscuro en tu app en una máquina configurada en claro recibe un widget claro, y el que eligió el claro en una máquina oscura recibe lo contrario.

Me topé exactamente con esto en el propio widget de BugDump esta semana. La corrección es pequeña, pero el razonamiento vale para cualquier widget de terceros que embebas: burbujas de chat, botones de feedback, banners de cookies, widgets de ayuda. Todos tienen que responder a la misma pregunta, ¿qué tema me toca?, y la mayoría la responde mirando al lugar equivocado.

Tres lugares de los que puede venir un tema

Cuando alguien dice «el tema del usuario», puede referirse a tres cosas distintas:

  1. La preferencia del sistema operativo. Lo que el navegador informa a través de prefers-color-scheme. Se configura una vez en los ajustes del sistema y la comparten todos los sitios.

  2. El ajuste de la app. Lo que el usuario eligió en tu selector: claro, oscuro o «sistema». Se guarda en una cookie, en localStorage o en su cuenta.

  3. Lo que la app pinta de verdad. Normalmente una clase o un atributo en <html>, que sale del ajuste, y del sistema operativo cuando el ajuste es «sistema».

Tu app vive en el segundo y el tercero. La mayoría de los widgets embebidos solo conocen el primero, porque es el único que pueden leer sin tu ayuda.

Por qué prefers-color-scheme es la señal equivocada

Un widget que se da estilo con una media query prefers-color-scheme hace una suposición razonable: la mayoría de la gente que quiere modo oscuro se lo ha pedido a su sistema operativo. Para un sitio sin ajuste de tema propio, la suposición es correcta.

Deja de serlo en cuanto tu app tiene un selector. La media query informa la preferencia del sistema operativo y nada más. Una página no puede cambiar lo que informa, así que por mucho cuidado que ponga tu app al aplicar su tema, un widget que lee la media query nunca se entera.

El desajuste solo aparece para los usuarios cuyo ajuste en la app difiere del de su sistema operativo, y precisamente por eso sobrevive a las pruebas: los desarrolladores suelen probar con los dos iguales.

Por qué el widget no puede simplemente leer tu página

La alternativa obvia es que el widget mire tu página: buscar una clase dark en <html>, vigilarla con un MutationObserver y seguirla.

Eso funciona en los sitios que siguen exactamente esa convención y falla sin avisar en todos los demás. La estrategia de clases de Tailwind pone dark en el elemento raíz. Otros sitios usan data-theme="dark", un atributo data-mode, una clase en <body> o un conjunto de propiedades personalizadas de CSS sin ninguna marca. Algunos pintan oscuro por defecto y marcan el claro. Un widget que adivina se equivocará con alguien, y de una forma difícil de diagnosticar.

La página conoce su propio tema. El diseño fiable es que la página lo diga.

Un contrato en dos partes que funciona

Lo que elegí al final, y lo que recomendaría a cualquiera que construya un widget embebible, tiene dos partes.

1. Un tema inicial, declarado de entrada. El código de inserción acepta el tema como configuración:

HTML
<script
  src="https://bugdump.com/sdk/latest.js"
  async
  data-api-key="your-api-key"
  data-theme="dark"
></script>

Si tu servidor conoce la elección del usuario, escríbela directamente en la etiqueta. Así el widget tiene los colores correctos desde el primer pintado, antes de que se ejecute nada de tu JavaScript. El ajuste «sistema» de la app corresponde a auto, que devuelve la decisión al sistema operativo: el único caso en que la media query sí es la señal correcta.

2. Un setter para cada cambio posterior.

JavaScript
bugdump('setTheme', 'dark'); // 'light', 'dark' or 'auto'

Llámalo desde tu selector. El widget cambia al instante, incluso con un panel ya abierto.

Ninguna de las dos partes basta por sí sola. Configuración sin setter es lo que yo tenía antes: correcto al cargar, desfasado tras el primer cambio hasta recargar. Un setter sin configuración significa que el widget se pinta con un tema por defecto y luego cambia a la vista de todos cuando por fin se ejecuta tu código.

Cómo conectarlo a un framework

En React basta un componente que no renderiza nada:

tsx
import { useEffect } from 'react';

export function WidgetThemeSync({ theme }: { theme: 'light' | 'dark' | 'system' }) {
  useEffect(() => {
    window.bugdump?.('setTheme', theme === 'system' ? 'auto' : theme);
  }, [theme]);

  return null;
}

Dos detalles marcan la diferencia entre que esto funcione y que casi funcione.

Sincroniza el ajuste, no el color resuelto. Muchos proveedores de tema exponen ambos: la elección del usuario (light, dark, system) y el color aplicado en ese momento. El valor aplicado suele calcularse después del primer render, así que durante un instante puede tener un valor provisional, normalmente claro. Sincronizarlo empuja ese valor provisional al widget y lo hace parpadear en claro a todos los usuarios con modo oscuro en cada carga de página. El ajuste es correcto desde el primer render.

Deja que «sistema» siga siendo «sistema». Es tentador resolverlo tú y pasar light o dark. Pasa auto en su lugar, y el widget seguirá los cambios del sistema operativo por su cuenta, sin que tengas que conectar ningún evento.

Notas para quien crea widgets

Si estás del otro lado, construyendo algo que otros embeben, hubo unas cuantas decisiones que me simplificaron la implementación.

Pon el tema en un solo elemento y deja que CSS haga el resto. La paleta de mi widget es un conjunto de propiedades personalizadas de CSS definidas en el shadow host, y el tema es una clase en ese host. Cambiar de tema es cambiar una clase: sin volver a renderizar, sin perder estado, y un panel abierto se actualiza ahí mismo.

Revisa cada raíz que montas. Mi editor de anotaciones de capturas de pantalla se montaba por separado, directamente en document.body, con su propio shadow root y su propia copia de la paleta. Nunca veía la clase de tema del host y seguía en silencio al sistema operativo. Nadie lo notó mientras todo seguía al sistema operativo; en cuanto los temas explícitos funcionaron, se convirtió en la única superficie clara de una interfaz oscura. Si tu widget monta modales, tooltips u overlays fuera de su raíz principal, cada uno necesita también el tema.

Haz que el setter se pueda llamar pronto sin riesgo. Los widgets embebidos suelen cargarse con async, así que la primera llamada a setTheme de una página puede llegar fácilmente antes que el script. Un pequeño stub con cola de comandos, el patrón que los snippets de analítica llevan años usando, guarda las llamadas hasta que arranca el SDK y luego las ejecuta en orden.

No conviertas la reinicialización en la respuesta. «Destruir y reinicializar con opciones nuevas» técnicamente permite cambios en tiempo de ejecución, pero tira todo lo que el widget tenía: logs en búfer, un formulario abierto, un mensaje a medio escribir. El tema es la opción que los usuarios cambian más a menudo con la página abierta. Merece un setter.

Cómo funciona en BugDump

Desde el SDK 0.0.39, el widget recibe un tema inicial a través de theme o data-theme y lo cambia sin recargar con setTheme(), ya sea desde la cola de comandos de la etiqueta script o en la instancia de npm. El editor de capturas de pantalla sigue el mismo tema.

La entrada del registro de cambios tiene los detalles, y Adaptar el widget al tema de tu app tiene ejemplos listos para copiar en React, Vue y JavaScript puro.

Te puede interesar