
While working on a client project last week, building an embeddable AI chat widget, I ran into a theming challenge. The client wanted the widget to support light and dark modes, but not based on the entire page’s theme. The widget needed to be self-contained, meaning its theme shouldn’t care what the rest of the site was doing.
Tailwind CSS, of course, has a well-known dark mode setup using the .dark
class or the user’s system preference via media
. But neither of those worked for this use case. I didn’t want to rely on the host page’s global classes or ask developers to manually toggle .dark
on the <html>
tag.
So I went with data-theme
, scoped right inside the widget.
Let’s break down how I pulled this off, and why you might want to do the same.
The problem: Tailwind’s .dark
class is global
The traditional Tailwind setup expects dark mode to be triggered globally:
<html class="dark">
<div class="bg-white dark:bg-black">
...contnet goes here...
</div>
</html>
This is fine for full apps or sites you control, but it doesn’t work for a standalone widget injected into other websites. I needed something local, something that could say:
“This section right here is in dark mode. Ignore everything else.”
Here comes the data-theme
attribute
Turns out you can scope styles in Tailwind using data-theme
, especially if you’re using DaisyUI or a bit of custom plugin magic.
Here’s how I used it:
<div data-theme="dark" class="p-4 rounded">
<p class="text-base-content">I'm in dark mode</p>
</div>
Simple, right? That data-theme="dark"
attribute activates the theme just for that section.
No need to mess with the global <html>
or <body>
tag. No classList juggling. Just drop the attribute and style your stuff.
Tailwind config: adding support for data-theme
Tailwind itself doesn’t understand data-theme
out of the box, you need to either use a plugin like DaisyUI (which comes with theme support baked in) or define your own variants.
Here’s how I did it without DaisyUI:
// tailwind.config.js
const plugin = require('tailwindcss/plugin');
module.exports = {
content: ['./index.html', './src/**/*.{ts,tsx,js,jsx}'],
theme: {
extend: {
colors: {
surface: {
light: '#ffffff',
dark: '#1e1e1e',
},
},
},
},
plugins: [
plugin(function ({ addVariant }) {
addVariant('theme-dark', '&[data-theme="dark"]');
addVariant('theme-light', '&[data-theme="light"]');
}),
],
};
Now I could write scoped variants like this:
<div
data-theme="dark"
className="theme-dark:bg-surface-dark theme-dark:text-white theme-light:bg-surface-light theme-light:text-black"
>
Scoped theming FTW.
</div>
This gave me the freedom to switch themes without global class shenanigans.
Theme toggle logic (super simple)
Since we’re working with data-theme
, toggling themes is as easy as flipping an attribute:
const container = document.getElementById('my-widget');
container?.setAttribute('data-theme', 'dark');
// or:
container?.setAttribute('data-theme', 'light');
No need for classList.add/remove
, no dependency on the outer DOM. Just update the attribute and Tailwind + CSS does the rest.
Bonus: it plays nice with Shadow DOM
This was huge for me. The chat widget I was working on runs inside a Shadow DOM, so relying on global classes like .dark
isn’t an option anyway. Scoped data-theme
attributes respect DOM boundaries and don’t leak outside.
Here’s a simplified Shadow DOM example:
const shadowRoot = myWidget.attachShadow({ mode: 'open' });
const wrapper = document.createElement('div');
wrapper.setAttribute('data-theme', 'dark');
wrapper.innerHTML = `
<div class="p-4 theme-dark:bg-gray-900 theme-dark:text-white">
This dark mode stays inside the widget.
</div>
`;
shadowRoot.appendChild(wrapper);
That data-theme="dark"
only affects the content inside the Shadow DOM. Exactly what I needed.
Could I have used .dark
?
Technically… yes. I could’ve passed a prop or class and toggled .dark
on the container, then told Tailwind to use class
-based dark mode in the config.
But I’d still be relying on global CSS logic, and the whole point was to decouple the widget from the host site’s theme system. data-theme
gave me that clean separation with minimal fuss.
A few quick lessons learned
- You can use
data-theme
anywhere, not just on<html>
or<body>
. - It works perfectly for scoped sections, widgets, or theme previews.
- With a little Tailwind plugin magic, it’s just as powerful as the
.dark
class, and honestly, way more flexible. - If you’re using DaisyUI, you get all this out of the box. But even without it, it’s just a few lines of config.
Wrapping up
On this project just trying to add a theme toggle, and came out appreciating how simple scoped theming can be when you use data-theme
. Whether you’re building a full app or a tiny embeddable widget like I was, this approach keeps your theme logic modular, clear, and zero-drama.
Next time you’re setting up dark mode, ask yourself: does this really need to be global? If not, try scoping it with data-theme
. You’ll probably like how it feels.