Warum dein Chat-Widget deinen Dark-Mode-Schalter ignoriert

Engineering

Du hast einen Dark Mode gebaut. Ein Besucher klickt auf das Mond-Symbol, die ganze Seite wird in einem weichen Übergang dunkel, und unten rechts bleibt ein knallweißes Rechteck genau so, wie es war. Der Support-Chat.

Eine Kleinigkeit, aber eine von denen, die auffallen, weil es der einzige Teil der Seite ist, der erkennbar nicht von dir stammt. Hier steht, warum eingebettete Widgets das so oft falsch machen, warum die naheliegenden Lösungen nicht funktionieren und was ein Widget bieten muss, damit deines es richtig macht.

Es gibt zwei Dark Modes, nicht einen

„Dark Mode“ kann auf einer Website zwei verschiedene Dinge bedeuten.

  • Die Einstellung des Betriebssystems. Der Besucher hat seinen Laptop auf dunkel gestellt. Der Browser gibt das über die Media Query prefers-color-scheme weiter.

  • Die Einstellung deiner Website. Ein Schalter in deinem Header, gespeichert in localStorage oder einem Cookie und meist als Klasse dark oder als Attribut data-theme auf <html> gesetzt.

Die meisten Websites mit Schalter bieten drei Optionen, Hell, Dunkel und System, eben weil die beiden sich widersprechen. Viele Leute nutzen ein helles Betriebssystem und wollen nachts trotzdem eine dunkle Doku, ein dunkles Dashboard, überhaupt alles dunkel.

Ein Widget eines Drittanbieters sieht die erste Einstellung. Die zweite sieht es nur, wenn du sie ihm mitteilst.

Warum „auto“ nicht reicht

Fast jedes einbettbare Widget hat eine Theme-Option, und fast jedes bietet auto an:

HTML
<script src="https://widget.example.com/chat.js" data-theme="auto"></script>

auto heißt „folge prefers-color-scheme“. Für eine Website ohne Schalter ist das genau richtig, deshalb ist es ein vernünftiger Standard. Genau falsch wird es, sobald deine Website einen eigenen Schalter hat: Ein Besucher mit hellem Betriebssystem, der auf deiner Website dunkel gewählt hat, bekommt eine dunkle Seite und ein helles Widget. Die Media Query sieht die Klasse an deinem <html>-Element nicht, weil sie sich deine Seite überhaupt nicht ansieht.

Wer data-theme="dark" fest einträgt, verschiebt das Problem nur zu den Besuchern, die hell gewählt haben.

Warum CSS das nicht löst

Der nächste Reflex ist, hineinzugreifen und das Widget umzustylen:

CSS
.dark .chat-panel { background: #111; color: #eee; }

Bei einem gut gebauten Widget bewirkt das nichts, und zwar mit Absicht. Widgets, die auf fremden Seiten leben, rendern in einem Shadow Root, damit das CSS der Host-Seite sie nicht kaputt macht und ihres nicht die Host-Seite. Dein Selektor kommt über diese Grenze nie hinweg.

Manche Widgets lassen ein paar CSS Custom Properties durch, und es ist verlockend, sie über deine Dark-Mode-Klasse zu setzen. Lass es: Diese Namen sind Interna des Widgets, nicht seine API. Sie ändern sich ohne Changelog-Eintrag, und dein Dark Mode geht unbemerkt mit einem Release kaputt, das du nie gesehen hast.

Warum Neuladen alles schlimmer macht

Manche Widget-Dokus empfehlen die Brechstange: das Widget zerstören und mit dem neuen Theme neu erstellen.

JavaScript
Widget.destroy();
Widget.init({ ...config, theme: 'dark' });

Das funktioniert insofern, als sich die Farben ändern. Es unmountet aber auch alles, womit der Besucher gerade beschäftigt war. Das offene Panel schließt sich. Die Antwort, die noch gestreamt wurde, bricht mitten im Satz ab. Und oft klickt jemand auf den Schalter, weil er spät abends noch etwas liest: der denkbar schlechteste Moment, seine Unterhaltung zu verwerfen.

Was ein Widget wirklich bieten muss

Das Theme gehört der Host-Seite, also muss die Host-Seite dem Widget sagen können, wenn es sich ändert. Dafür reicht eine Methode:

JavaScript
KookeeChat.setTheme('dark'); // 'light', 'dark' oder 'auto'

Die Methode ist der einfache Teil. Brauchbar wird sie durch ihr Verhalten:

  1. Sie wirkt an Ort und Stelle. Kein Remount: Ein offenes Panel bleibt offen, und eine Antwort, die gerade gestreamt wird, läuft weiter. Nur die Farben ändern sich.

  2. Sie funktioniert, bevor das Widget geladen ist. Widget-Skripte laden async, und dein Theme-Code läuft oft zuerst. Ein früher Aufruf sollte in eine Warteschlange kommen, statt verworfen zu werden oder einen Fehler auszulösen.

  3. Die Reihenfolge ist ihr egal. Ob dein Aufruf vor oder nach dem Moment kam, in dem das Widget seine eigene Konfiguration gelesen hat: Der explizite Aufruf gewinnt.

  4. auto bleibt eine Option. „System“ ist eine echte Wahl in deinem Schalter, und das Widget sollte ihr live folgen können.

Wenn du ein Chat- oder Support-Widget auswählst und dein Produkt einen Theme-Schalter hat, lohnt es sich, diese vier Punkte vorher zu prüfen. Am ersten scheitern die meisten Umsetzungen, ohne dass es jemand merkt.

An den eigenen Schalter anschließen

Die einfachste Variante sitzt in der Funktion, die dein Schalter ohnehin aufruft:

JavaScript
function applyTheme(choice) { // 'light' | 'dark' | 'system'
  const dark = choice === 'dark' || (choice === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
  document.documentElement.classList.toggle('dark', dark);
  localStorage.setItem('theme', choice);

  KookeeChat.setTheme(choice === 'system' ? 'auto' : choice);
}

Die letzte Zeile macht aus system 'auto', nicht das, was system gerade ergibt. Übergibst du den aufgelösten Wert, friert das Widget in diesem Moment ein: Wenn das Betriebssystem des Besuchers bei Sonnenuntergang auf dunkel umschaltet, folgt deine Seite, das Widget aber nicht. Übergibst du 'auto', beobachtet das Widget das Betriebssystem selbst.

Das Start-Theme ohne Aufblitzen setzen

setTheme() kümmert sich um Änderungen. Für den ersten Paint gibst du dem Widget die Wahl, die du schon kennst, so früh, wie dein eigenes Theme-Skript läuft:

JavaScript
const stored = localStorage.getItem('theme'); // 'light' | 'dark' | 'system' | null
KookeeChat.setTheme(stored === 'light' || stored === 'dark' ? stored : 'auto');

Läuft das, bevor das Widget-Skript geladen ist, hält der kleine Warteschlangen-Stub aus dem Installations-Snippet den Aufruf fest, bis das Skript da ist, sofern 'setTheme' in der Methodenliste des Stubs steht. Renderst du die Seite auf dem Server und kennst dort das Theme, erledigt ein Attribut data-theme am Script-Tag dasselbe ganz ohne JavaScript.

Wenn der Schalter nicht dir gehört

Bei einem Shopify-Theme, einem WordPress-Theme oder einem Designsystem, das den Dark Mode für dich verwaltet, gibt es vielleicht keine Schalterfunktion, in die du eine Zeile einfügen kannst. Beobachte stattdessen das Ergebnis:

JavaScript
const root = document.documentElement;
const sync = () => KookeeChat.setTheme(root.classList.contains('dark') ? 'dark' : 'light');

new MutationObserver(sync).observe(root, { attributes: true, attributeFilter: ['class', 'data-theme'] });
sync();

Passe die Prüfung daran an, wie dein Theme den Dark Mode markiert: Ein Attribut data-theme="dark" ist genauso verbreitet wie eine Klasse. Hier wird absichtlich der aufgelöste Wert übergeben: Wenn du nur das Ergebnis siehst, ist es richtig, der Seite genau zu folgen, und dein Theme löst „System“ schon für dich auf.

In React

Ist das Widget eine React-Komponente, ist das Theme einfach ein Prop, und du musst nur deine aktuelle Einstellung übergeben. Mit next-themes:

tsx
const { theme } = useTheme(); // 'light' | 'dark' | 'system' | undefined

<KookeeChatWidget
  clientOptions={{ apiKey }}
  theme={theme === 'light' || theme === 'dark' ? theme : 'auto'}
/>;

Nimm theme, nicht resolvedTheme. resolvedTheme ist beim Server-Rendering und beim ersten Client-Render unbekannt, also startet ein Widget, das davon gespeist wird, hell und springt bei jedem Besucher mit Dark Mode einen Moment später um. theme fällt mit dieser Zuordnung auf 'auto' zurück, und das ist für die meisten von ihnen schon richtig.

Das Theme kommt von deiner Seite

auto folgt dem Betriebssystem, und von einem Schalter auf deiner Website weiß das Betriebssystem per Definition nichts. Ein Widget auf deiner Seite sollte sein Theme von deiner Seite bekommen: zur Laufzeit, an Ort und Stelle und ohne zu verlieren, womit der Besucher gerade beschäftigt war.


Das KI-Chat-Widget von Kookee kann das schon heute. Beim Script-Tag rufst du KookeeChat.setTheme() auf, in React aktualisiert sich der Prop theme von KookeeChatWidget live. Das React-Widget ist die eine Ausnahme von der Shadow-Root-Regel oben: Es rendert direkt in deiner Seite, und die Variablen --kookee-*, die es liest, sind dokumentiert, also kannst du sie gefahrlos setzen. Die Theme-Optionen beschreibt der Artikel Aussehen des Chat-Widgets anpassen, die vollständige API der Artikel Das Chat-Widget per JavaScript steuern.