Dark Mode für eingebettete Widgets: dem Theme-Schalter der App folgen

Entwickler-Tools

Du lieferst einen Dark Mode aus. Der Schalter sitzt im Header, merkt sich die Wahl, und die ganze App wechselt sauber das Theme. Dann öffnet jemand das Feedback-Widget in der Ecke, und es leuchtet immer noch grellweiß.

Oder subtiler: Es ist dunkel, aber nur, weil der Laptop dunkel eingestellt ist. Wer in deiner App den Dark Mode gewählt hat, aber auf einem Rechner mit hellem System arbeitet, bekommt ein helles Widget, und wer auf einem dunklen Rechner hell gewählt hat, bekommt das Gegenteil.

Genau darauf bin ich diese Woche im Widget von BugDump selbst gestoßen. Der Fix ist klein, aber die Überlegung gilt für jedes Widget von Drittanbietern, das du einbettest: Chat-Launcher, Feedback-Buttons, Cookie-Banner, Hilfe-Beacons. Sie alle müssen dieselbe Frage beantworten, nämlich welches Theme sie haben sollen, und die meisten beantworten sie anhand der falschen Quelle.

Drei Quellen, aus denen ein Theme kommen kann

Wenn von dem Theme die Rede ist, das jemand eingestellt hat, kann das drei verschiedene Dinge bedeuten:

  1. Die Einstellung des Betriebssystems. Was der Browser über prefers-color-scheme meldet. Einmal in den Systemeinstellungen festgelegt und für jede Website gleich.

  2. Die Einstellung der App. Was in deinem Schalter gewählt wurde: hell, dunkel oder „System“. Gespeichert in einem Cookie, in localStorage oder im Account.

  3. Was die App tatsächlich rendert. Meist eine Klasse oder ein Attribut auf <html>, abgeleitet aus der Einstellung und, wenn sie auf „System“ steht, aus dem Betriebssystem.

Deine App arbeitet mit der zweiten und dritten. Die meisten eingebetteten Widgets kennen nur die erste, weil sie die einzige ist, die sie ohne deine Hilfe lesen können.

Warum prefers-color-scheme das falsche Signal ist

Ein Widget, das sich per Media Query prefers-color-scheme stylt, trifft eine vernünftige Annahme: Die meisten, die einen Dark Mode wollen, haben ihn im Betriebssystem eingestellt. Für eine Website ohne eigene Theme-Einstellung stimmt diese Annahme.

Sie stimmt nicht mehr, sobald deine App einen Schalter hat. Die Media Query meldet die Einstellung des Betriebssystems und sonst nichts. Eine Seite kann nicht ändern, was die Media Query meldet. Wie sorgfältig deine App ihr Theme auch anwendet, ein Widget, das die Media Query liest, erfährt nie davon.

Der Unterschied fällt nur bei Leuten auf, deren App-Einstellung von der Systemeinstellung abweicht. Genau deshalb übersteht er jeden Test: Beim Entwickeln stehen beide meist auf demselben Wert.

Warum das Widget deine Seite nicht einfach auslesen kann

Die naheliegende Alternative: Das Widget schaut sich deine Seite an, sucht eine Klasse dark auf <html>, beobachtet sie mit einem MutationObserver und zieht mit.

Das funktioniert bei Websites, die genau dieser Konvention folgen, und scheitert bei allen anderen, ohne dass es jemand merkt. Die Class-Strategie von Tailwind setzt dark auf das Root-Element. Andere Websites nutzen data-theme="dark", ein data-mode-Attribut, eine Klasse auf <body> oder eine Reihe von CSS Custom Properties ganz ohne Markierung. Manche rendern standardmäßig dunkel und markieren hell. Ein Widget, das rät, liegt bei irgendwem falsch, und zwar so, dass es dort schwer zu diagnostizieren ist.

Die Seite kennt ihr eigenes Theme. Zuverlässig ist nur ein Design, bei dem die Seite es dem Widget mitteilt.

Ein zweiteiliger Vertrag, der funktioniert

Meine Lösung, die ich auch allen empfehlen würde, die ein einbettbares Widget bauen, besteht aus zwei Teilen.

1. Ein Start-Theme, vorab festgelegt. Der Einbettungscode nimmt das Theme als Konfiguration entgegen:

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

Wenn dein Server die Wahl der Person kennt, render sie direkt in das Tag. Dann hat das Widget schon beim allerersten Paint die richtigen Farben, bevor irgendein JavaScript von dir gelaufen ist. Die App-Einstellung „System“ wird zu auto und überlässt die Entscheidung wieder dem Betriebssystem. Das ist der eine Fall, in dem die Media Query doch das richtige Signal ist.

2. Ein Setter für jede spätere Änderung.

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

Ruf ihn aus deinem Schalter auf. Das Widget wechselt sofort, auch ein Panel, das schon offen ist.

Keiner der beiden Teile reicht allein. Konfiguration ohne Setter hatte ich vorher ausgeliefert: beim Laden korrekt, nach dem ersten Umschalten veraltet bis zum Neuladen. Ein Setter ohne Konfiguration heißt, dass das Widget erst in einem Standard-Theme rendert und dann sichtbar umspringt, sobald dein Code nachzieht.

Einbindung in ein Framework

In React erledigt das eine Komponente, die nichts rendert:

tsx
import { useEffect } from 'react';

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

  return null;
}

Zwei Details entscheiden darüber, ob das funktioniert oder nur fast.

Synchronisiere die Einstellung, nicht die aufgelöste Farbe. Viele Theme-Provider liefern beides: die Wahl der Person (light, dark, system) und die gerade angewendete Farbe. Der angewendete Wert wird oft erst nach dem ersten Render berechnet und kann deshalb kurz einen Platzhalter enthalten, meist hell. Synchronisierst du ihn, landet dieser Platzhalter im Widget, und es blitzt bei jedem Seitenaufruf für alle mit Dark Mode hell auf. Die Einstellung stimmt ab dem ersten Render.

Lass „System“ auch „System“ bleiben. Es ist verlockend, den Wert selbst aufzulösen und light oder dark zu übergeben. Übergib stattdessen auto, dann folgt das Widget Änderungen im Betriebssystem von selbst, ohne dass du Events verdrahten musst.

Hinweise für alle, die Widgets bauen

Falls du auf der anderen Seite stehst und selbst etwas baust, das andere einbetten: Ein paar Entscheidungen haben mir die Umsetzung leicht gemacht.

Setz das Theme auf ein einziges Element und überlass den Rest dem CSS. Die Palette meines Widgets ist ein Satz CSS Custom Properties, definiert auf dem Shadow Host, und das Theme ist eine Klasse auf diesem Host. Ein Theme-Wechsel ist ein einziger Klassentausch: kein Re-Render, kein verlorener State, und ein offenes Panel aktualisiert sich an Ort und Stelle.

Prüf jeden Root, den du mountest. Mein Editor für Screenshot-Anmerkungen wurde separat gemountet, direkt auf document.body, mit eigenem Shadow Root und eigener Kopie der Palette. Er sah die Theme-Klasse des Hosts nie und folgte unbemerkt weiter dem Betriebssystem. Solange alles dem Betriebssystem folgte, fiel das niemandem auf. Sobald explizite Themes funktionierten, war er die einzige helle Fläche in einer dunklen Oberfläche. Wenn dein Widget Modals, Tooltips oder Overlays außerhalb seines Haupt-Roots mountet, braucht jedes davon ebenfalls das Theme.

Mach den Setter schon vor dem Laden aufrufbar. Einbettungen laden meist async, also kann der erste setTheme-Aufruf einer Seite leicht vor dem Skript ankommen. Ein kleiner Command-Queue-Stub, ein Muster, das Analytics-Snippets seit Jahren nutzen, hält Aufrufe fest, bis das SDK gestartet ist, und spielt sie dann der Reihe nach ab.

Mach Neuinitialisierung nicht zur Lösung. „Zerstören und mit neuen Optionen neu initialisieren“ unterstützt Änderungen zur Laufzeit zwar technisch, wirft aber alles weg, was das Widget gerade hält: gepufferte Logs, ein offenes Formular, eine halb geschriebene Nachricht. Das Theme ist die Option, die Leute am häufigsten ändern, während eine Seite offen ist. Sie verdient einen Setter.

So sieht das in BugDump aus

Seit SDK 0.0.39 nimmt das Widget ein Start-Theme über theme oder data-theme entgegen und ändert es an Ort und Stelle über setTheme(), entweder über die Command Queue des Script-Tags oder an der npm-Instanz. Der Screenshot-Editor folgt demselben Theme.

Details stehen im Changelog-Eintrag, und Das Widget an das Theme deiner App anpassen hat Beispiele zum Kopieren für React, Vue und reines JavaScript.

Mehr zum Thema