You ship dark mode. The toggle sits in the header, it remembers the choice, and the whole app flips cleanly. Then someone opens the feedback widget in the corner, and it is still blinding white.
Or, more subtly: it is dark, but only because their laptop is. The user who picked dark mode in your app on a machine set to light gets a light widget, and the user who picked light on a dark machine gets the opposite.
We hit exactly this in BugDump's own widget this week. The fix is small, but the reasoning applies to every third-party widget you embed: chat launchers, feedback buttons, cookie banners, help beacons. They all have to answer the same question — which theme should I be? — and most of them answer it from the wrong place.
Three places a theme can come from
When people say "the user's theme", they can mean three different things:
The operating system preference. What the browser reports through
prefers-color-scheme. Set once in system settings and shared by every site.The app's setting. What the user picked in your toggle — light, dark, or "system". Stored in a cookie,
localStorage, or their account.What the app actually renders. Usually a class or attribute on
<html>, derived from the setting, and from the OS when the setting is "system".
Your app lives in the second and third. Most embedded widgets only know about the first, because it is the only one they can read without your help.
Why prefers-color-scheme is the wrong signal
A widget that styles itself with a prefers-color-scheme media query is making a reasonable guess: most people who want dark mode have asked their OS for it. For a site with no theme setting of its own, that guess is right.
It stops being right the moment your app has a toggle. The media query reports the OS preference and nothing else. A page cannot change what it reports, so however carefully your app applies its theme, a widget reading the media query never finds out.
The mismatch only shows up for users whose app setting differs from their OS setting, which is exactly why it survives testing: developers tend to test with the two matching.
Why the widget can't just read your page
The obvious alternative is for the widget to look at your page: check for a dark class on <html>, watch it with a MutationObserver, and follow along.
That works for sites that follow that exact convention, and fails silently for everyone else. Tailwind's class strategy puts dark on the root element. Other sites use data-theme="dark", a data-mode attribute, a class on <body>, or a set of CSS custom properties with no marker at all. Some render dark by default and mark light. A widget that guesses will be wrong for someone, in a way that is hard for them to diagnose.
The page knows its own theme. The reliable design is for the page to say so.
A two-part contract that works
What we landed on — and what we would suggest to anyone building an embeddable widget — has two parts.
1. A starting theme, declared up front. The embed code accepts the theme as configuration:
<script
src="https://bugdump.com/sdk/latest.js"
async
data-api-key="your-api-key"
data-theme="dark"
></script>
If your server knows the user's choice, render it straight into the tag. The widget then has the right colors on its very first paint, before any of your JavaScript has run. An app setting of "system" maps to auto, which hands the decision back to the OS — the one case where the media query is the right signal.
2. A setter for every change after that.
bugdump('setTheme', 'dark'); // 'light', 'dark' or 'auto'
Call it from your toggle. The widget switches immediately, including a panel that is already open.
Neither part is enough on its own. Configuration without a setter is what we shipped before: correct on load, stale after the first toggle until a reload. A setter without configuration means the widget renders in a default theme and then visibly switches once your code catches up.
Wiring it into a framework
In React, a component that renders nothing does the job:
import { useEffect } from 'react';
export function WidgetThemeSync({ theme }: { theme: 'light' | 'dark' | 'system' }) {
useEffect(() => {
window.bugdump?.('setTheme', theme === 'system' ? 'auto' : theme);
}, [theme]);
return null;
}
Two details make the difference between this working and almost working.
Sync the setting, not the resolved color. Many theme providers expose both: the user's choice (light, dark, system) and the color currently applied. The applied value is often computed after the first render, so for a moment it can hold a placeholder — typically light. Syncing it pushes that placeholder into the widget and flashes it light for every dark-mode user on every page load. The setting is correct from the first render.
Let "system" stay "system". It is tempting to resolve it yourself and pass light or dark. Pass auto instead, and the widget follows OS changes on its own, with no event wiring on your side.
Notes for widget authors
If you are on the other side of this — building something other people embed — a few choices made the implementation straightforward for us.
Put the theme on one element and let CSS do the rest. Our widget's palette is a set of CSS custom properties defined on the shadow host, and the theme is a class on that host. Switching theme is one class swap: no re-render, no lost state, and an open panel updates in place.
Audit every root you mount. Our screenshot annotation editor was mounted separately, directly on document.body, with its own shadow root and its own copy of the palette. It never saw the host's theme class and quietly kept following the OS. Nobody noticed while everything followed the OS; the moment explicit themes worked, it became the one light surface in a dark UI. If your widget mounts modals, tooltips, or overlays outside its main root, each of them needs the theme too.
Make the setter safe to call early. Embeds usually load async, so a page's first setTheme call can easily arrive before the script does. A small command-queue stub — the pattern analytics snippets have used for years — holds calls until the SDK boots and replays them in order.
Don't make re-initialization the answer. "Destroy and re-init with new options" technically supports runtime changes, but it throws away whatever the widget was holding: buffered logs, an open form, a half-written message. Theme is the option users change most often while a page is open. It deserves a setter.
What this looks like in BugDump
As of SDK 0.0.39, the widget takes a starting theme through theme or data-theme and changes it in place through setTheme() — from the script tag's command queue, or on the npm instance. The screenshot editor follows the same theme.
The changelog entry has the details, and Matching the widget to your app's theme has copy-paste examples for React, Vue, and plain JavaScript.
BugDump adds a bug report button to your site with one script tag, and every report arrives with console logs, network requests, session replay, and environment data attached. The widget follows your app's light and dark themes.