🌗 Tailwind CSS Theme Variants
This Tailwind CSS plugin registers variants for theming beyond just light and dark modes without needing custom properties. It has support for
- Controlling themes with
- Media queries, like
prefers-color-scheme
, print
, or anything you want - CSS selectors, like classes and data attributes
- Or both at the same time!
- Responsive variants
- Stacking on extra variants, like
hover
so you can change a link's hover color depending on the theme - Falling back to a certain theme when no other one could become active, like if a visitor's browser doesn't support JavaScript or the new
prefers-
media queries - As many themes and groups of themes as you want: light theme, dark theme, red theme, blue theme—just bring your own definitions!
You are recommended to check out the comparison table of all Tailwind CSS theming plugins below before committing to any one. By the way, you might have noticed this plugin's documentation / README
is very long—don't let that frighten you! I designed it to be overdocumented and as exhaustive as possible, and since most of that length is made up of long code snippets, it's shorter than it looks and you don't need to go through it all to do well!
However, if you want your site to have a very large number of themes (say, 4 or more) or potentially infinite themes (such as could be configured by your users), then this plugin is not for you. You will probably be better off using a custom properties setup; refer back to that table 👇.
⬇️ Installation
npm install --save-dev tailwindcss-theme-variants
🛠 Basic usage
Using selectors to choose the active theme
With this Tailwind configuration,
const { tailwindcssThemeVariants } = require("tailwindcss-theme-variants");
module.exports = {
theme: {
backgroundColor: {
"gray-900": "#1A202C",
},
},
variants: {
backgroundColor: ["light", "dark"],
},
plugins: [
tailwindcssThemeVariants({
themes: {
light: {
selector: ".light-theme",
},
dark: {
selector: ".dark-theme",
},
},
}),
],
};
this CSS is generated:
.bg-gray-900 {
background-color: #1A202C;
}
:root.light-theme .light\:bg-gray-900 {
background-color: #1A202C;
}
:root.dark-theme .dark\:bg-gray-900 {
background-color: #1A202C;
}
After also enabling "light"
and "dark"
variants for textColor
and bringing in more colors from the default palette, we can implement a simple themed button in HTML like this:
<html class="light-theme">
<button class="light:bg-teal-200 dark:bg-teal-800 light:text-teal-700 dark:text-teal-100">
Sign up
</button>
</html>
This will result in dark blue text on a light blue background in the light theme, and light blue text on a dark blue background in the dark theme.
💡 You can choose more than just classes for your selectors. Other, good options include data attributes, like [data-padding=compact]
. You can go as crazy as .class[data-theme=light]:dir(rtl)
, for example, but I think that's a bad idea!
Using media queries to choose the active theme
You may rather choose to tie your theme selection to matched media queries, like prefers-color-scheme
:
const { tailwindcssThemeVariants, prefersLight, prefersDark } = require("tailwindcss-theme-variants");
module.exports = {
theme: {
backgroundColor: {
"teal-500": "#38B2AC",
},
},
variants: {
backgroundColor: ["light", "dark"],
},
plugins: [
tailwindcssThemeVariants({
themes: {
light: {
mediaQuery: prefersLight ,
},
dark: {
mediaQuery: prefersDark ,
},
},
}),
],
};
Which generates this CSS:
.bg-teal-500 {
background-color: #38B2AC
}
@media (prefers-color-scheme: light) {
.light\:bg-teal-500 {
background-color: #38B2AC;
}
}
@media (prefers-color-scheme: dark) {
.dark\:bg-teal-500 {
background-color: #38B2AC;
}
}
💡 Keep the variants
listed in the same order as in themes
in this plugin's configuration for consistency and the most expected behavior. In backgroundColor
's variants
, light
came first, then dark
, so we also list light
before dark
in tailwindcssThemeVariants
's themes
option. There is a planned feature that should make it unnecessary to remember this information, but until then, please follow this advice.
⚙️ Full configuration
This plugin expects configuration of the form
{
themes: {
[name: string]: {
selector?: string;
mediaQuery?: string;
}
};
baseSelector?: string;
fallback?: string | boolean;
variants?: {
[name: string]: (selector: string) => string;
};
}
Where each parameter means:
-
themes
: an object mapping a theme name to the conditions that determine whether or not the theme will be active.
-
selector
: a selector that has to be active on baseSelector
for this theme to be active. For instance, if baseSelector
is html
, and themes.light
's selector
is .light-theme
, then the light
theme's variant(s) will be in effect whenever html
has the light-theme
class on it.
-
mediaQuery
: a media query that has to be active for this theme to be active. For instance, if the reduced-motion
theme has mediaQuery
"@media (prefers-reduced-motion: reduce)"
(importable as prefersReducedMotion
), then the reduced-motion
variant(s) will be active whenever that media query matches: if the visitor's browser reports preferring reduced motion.
-
baseSelector
(default ""
(empty string) if you only use media queries to activate your themes, otherwise ":root"
): the selector that each theme's selector
will be applied to to determine the active theme.
-
fallback
(default false
): chooses a theme to fall back to when none of the media queries or selectors are active. You can either manually select a theme by giving a string like "solarized-dark"
or implicitly select the first one listed in themes
by giving true
.
⚠️ Passing a string referring to a theme name is deprecated in favor of the true
/false
approach, and is planned to be removed.
-
variants
(default is nothing): an object mapping the name of a variant to a function that gives a selector for when that variant is active.
For example, the importable even
variant takes a selector
and returns `${selector}:nth-child(even)`
. The importable groupHover
(which you are recommended to name "group-hover"
for consistency) variant returns `.group:hover ${selector}`
Examples
💡 At the time of writing, this documentation is a work in progress. For all examples, where I've done my best to stretch the plugin to its limits (especially towards the end of the file), see the test suite in tests/index.ts
.
Fallback
Media queries
With the same media-query-activated themes as above,
themes: {
light: {
mediaQuery: prefersLight ,
},
dark: {
mediaQuery: prefersDark ,
},
},
we can create a table to show what the active theme will be under all possible conditions:
Matching media query | Neither | prefers-color-scheme: light | prefers-color-scheme: dark |
---|
Active theme | None | light | dark |
---|
The whole point of the fallback feature is to address that None case. It could mean that the visitor is using a browser that doesn't support prefers-color-scheme
, such as IE11. Instead of leaving them on an unthemed site, we can "push" them into a particular theme by specifying fallback
.
themes: {
light: {
mediaQuery: prefersLight ,
},
dark: {
mediaQuery: prefersDark ,
},
},
fallback: "light",
Which will change the generated CSS to activate light
earlier than any media queries, so they can still override that declaration by being later in the file. You could think of light
as the default theme in this case.
.bg-teal-500 {
background-color: #38B2AC;
}
.light\:bg-teal-500 {
background-color: #38B2AC;
}
@media (prefers-color-scheme: light) {
.light\:bg-teal-500 {
background-color: #38B2AC;
}
}
@media (prefers-color-scheme: dark) {
.dark\:bg-teal-500 {
background-color: #38B2AC;
}
}
Which, in turn, changes the active theme table to:
Matching media query | Neither | prefers-color-scheme: light | prefers-color-scheme: dark |
---|
Active theme | light | light | dark |
---|
💡 Even though background-color
has been used in every example so far, theme variants are available for any utility.
Selectors
fallback
also works for selector-activated themes.
💡 If you control themes on your site by adding / removing classes or attributes on the html
or body
element with JavaScript, then visitors without JavaScript enabled would see the fallback
theme!
themes: {
light: {
selector: ".light-theme",
},
dark: {
selector: ".dark-theme",
},
},
fallback: "dark",
This generates:
.bg-gray-900 {
background-color: #1A202C;
}
:root.light-theme .light\:bg-gray-900 {
background-color: #1A202C;
}
:root:not(.light-theme) .dark\:bg-gray-900 {
background-color: #1A202C;
}
:root.dark-theme .dark\:bg-gray-900 {
background-color: #1A202C;
}
Which has the active theme table:
Matching selector | Active theme |
---|
Neither | dark |
---|
:root.light-theme | light |
---|
:root.dark-theme | dark |
---|
Stacked variants
💡 All of Tailwind CSS's core variants and more are bundled for use with this plugin. You can see the full list in src/variants.ts
.
By specifying variants
in this plugin's options, you can "stack" extra variants on top of the existing theme variants. (We call it stacking because there are multiple variants required, like in night:focus:border-white
, the border will only be white if the night
theme is active and the element is :focus
ed on).
Here's an example of combining prefers-contrast: high
(which you can import as prefersHighContrast
) with the :hover
variant (which you can import as hover
):
const { tailwindcssThemeVariants, hover, prefersHighContrast } = require("tailwindcss-theme-variants");
module.exports = {
theme: {
},
variants: {
backgroundColor: ["high-contrast"],
textColor: ["high-contrast", "high-contrast:hover"],
},
plugins: [
tailwindcssThemeVariants({
themes: {
"high-contrast": {
mediaQuery: prefersHighContrast ,
},
},
variants: {
"hover": hover ,
},
}),
],
};
You could create a simple card that uses contrast pleasant for fully sighted visitors, but intelligently switches to functional high contrast for those who specify it:
<div class="bg-gray-100 high-contrast:bg-white text-gray-800 high-contrast:text-black">
<h1>Let me tell you all about...</h1>
<h2>... this great idea I have!</h2>
<a href="text-blue-500 high-contrast:text-blue-700 hover:text-blue-600 high-contrast:hover:text-blue-900">
See more
</a>
</div>
Writing a custom variant function
You might need to write a variant function yourself if it's not export
ed with this plugin. It's common to use the same styles on links and buttons when they are hovered over or focused on, so you may want to make things easier for yourself and reduce duplication by creating a "hocus"
variant that activates for either :hover
or :focus
.
const { tailwindcssThemeVariants, hover, odd } = require("tailwindcss-theme-variants");
module.exports = {
theme: {
},
variants: {
opacity: [
"transparency-safe", "transparency-reduce",
"transparency-safe:hocus", "transparency-reduce:hocus",
],
},
plugins: [
tailwindcssThemeVariants({
themes: {
"transparency-safe": {
mediaQuery: prefersAnyTransparency ,
},
"transparency-reduce": {
mediaQuery: prefersReducedTransparency ,
},
},
fallback: true,
variants: {
hocus: (selector) => `${selector}:hover, ${selector}:focus`,
},
}),
],
};
With this, let's try making an icon button that's overlaid on top of an image in HTML. This button is generally translucent and becomes more opaque on hover or focus, but now can be made more visually distinct for visitors who need it.
<div>
<button
@click="..."
class="transparency-safe:opacity-25 transparency-safe:hocus:opacity-75
transparency-reduce:opacity-75 transparency-reduce:hocus:opacity-100">
<svg class="fill-current text-white bg-black positioning_classes...">
</svg>
</button>
<img src="..." class="positioning_classes...">
</div>
Another—complex—example: suppose you want to zebra stripe your tables, matching the current theme, and change it on hover:
const { tailwindcssThemeVariants, hover, odd } = require("tailwindcss-theme-variants");
module.exports = {
theme: {
},
variants: {
backgroundColor: [
"no-accent", "green-accent", "orange-accent",
"no-accent:hover", "green-accent:hover", "orange-accent:hover",
"no-accent:odd", "green-accent:odd", "orange-accent:odd",
"no-accent:odd:hover", "green-accent:odd:hover", "orange-accent:odd:hover",
],
},
plugins: [
tailwindcssThemeVariants({
baseSelector: "table.themed",
themes: {
"no-accent": { selector: "" },
"green-accent": { selector: ".themed-green" },
"orange-accent": { selector: ".themed-orange" },
},
variants: {
hover ,
odd ,
"odd:hover": (selector) => `${selector}:nth-child(odd):hover`,
},
}),
],
};
We can then implement the themeable table in HTML (Svelte) like so:
<table class="themed themed-green">
{#each people as person}
<tr class="no-accent:bg-white green-accent:bg-green-50 orange-accent:bg-orange-50
no-accent:hover:bg-gray-100 green-accent:hover:bg-green-100 orange-accent:hover:bg-orange-100
no-accent:odd:bg-gray-100 green-accent:odd:bg-green-100 orange-accent:orange-accent:odd:bg-orange-100
no-accent:odd:hover:bg-gray-200 green-accent:odd:hover:bg-green-200 orange-accent:odd:hover:bg-orange-100
">
<td>{person.firstName} {person.lastName}</td>
<td>{person.responsibility}</td>
</tr>
{/each}
</table>
Responsive variants
Responsive variants let you distinguish the current breakpoint per theme, letting you say lg:green-theme:border-green-200
to have a green-200
border only when the breakpoint is lg
(or larger) and the green-theme
is active, for instance.
⚠️ Responsive variants are automatically generated whenever responsive
is listed in the utility's variants
in the Tailwind CSS configuration, not this plugin's configuration. Also, because this feature is provided by Tailwind CSS rather than this plugin, you have to type breakpoint:
before the theme-name:
instead of after.
const { tailwindcssThemeVariants } = require("tailwindcss-theme-variants");
module.exports = {
theme: {
},
variants: {
textColor: ["responsive", "day", "night"]
},
plugins: [
tailwindcssThemeVariants({
themes: {
day: { selector: "[data-time=day]" },
night: { selector: "[data-time=night]" },
},
}),
],
};
With this, we could make the landing page's title line change color at different screen sizes "within" each theme:
<h1 class="day:text-black night:text-white
sm:day:text-orange-800 sm:night:text-yellow-100
lg:day:text-orange-600 lg:night:text-yellow-300">
The best thing that has ever happened. Ever.
</h1>
We could also make a group of themes for data density, like you can configure in GMail:
const { tailwindcssThemeVariants } = require("tailwindcss-theme-variants");
module.exports = {
theme: {
},
variants: {
padding: ["responsive", "comfortable", "compact"]
},
plugins: [
tailwindcssThemeVariants({
themes: {
comfortable: { selector: "[data-density=comfortable]" },
compact: { selector: "[data-density=compact]" },
},
fallback: true,
}),
],
};
This will allow us to configure the padding for each theme for each breakpoint, of a list of emails in the inbox (so original!):
<li class="comfortable:p-2 compact:p-0
md:comfortable:p-4 md:compact:p-1
xl:comfortable:p-6 xl:compact:p-2">
FWD: FWD: The real truth behind...
</li>
You can still stack extra variants even while using responsive variants.
TODO
Using both selectors and media queries
⚠️ If you use both selectors and media queries to activate themes, then make sure that each specified class is specified as an all or nothing approach. For instance, if you have winter
and summer
themes and want to add the winter:bg-teal-100
class, then you also need to add the summer:bg-orange-300
class. If you don't do this, then it will look like the values from an theme that's supposed to be inactive are "leaking" into the active theme.
Every feature previously discussed still works as you'd expect when you decide to also add selectors or media queries (whichever you weren't using before) to theme control. TODO
TODO: Show active theme tables for every example
Such as:
Match | Neither | prefers-color-scheme: light | prefers-color-scheme: dark |
---|
Neither | None | cyan | navy |
---|
:root.day | cyan | cyan | cyan |
---|
:root.night | navy | navy | navy |
---|
⚠️ If you are stacking variants on while using both selectors and media queries to activate themes, then make sure that each stacked variant is specified as an all or nothing approach on each element. For instance, if you have normal-motion
and reduced-motion
themes and want to add the reduced-motion:hover:transition-none
class, then you also need to add the normal-motion:hover:transition
class (or any value of transitionProperty
). If you don't do this, then it will look like the values from a theme that's supposed to be inactive are "leaking" into the active theme.
Fallback
TODO
Call the plugin more than once for multiple groups
TODO
The ultimate example: how I use every feature together in production
TODO
Alternatives
Both because there are many theme plugins for Tailwind CSS, and because what's the right way to do theming? is a frequently asked question, we've compiled this table listing every theme plugin to compare their features and ultimately answer that question:
Legend
TODO: add @variants
in CSS support? need to research. TODO: support for prefers-reduced-motion now that that's in tailwind core (or does that being available make it less useful for plugins to support?); so then how about prefers-reduced-transparency? arbitrary media queries?
Classes can be @apply
ed:
Native screens cannot have their generated classes @apply
ed, but you can still nest an @screen
directive within the element, like this:
.btn-blue {
@apply bg-blue-100 text-blue-800;
@screen dark {
@apply bg-blue-700 text-white;
}
}
This may require nesting support, provided by postcss-nested
or postcss-nesting
(part of postcss-preset-env
).
As for theme plugins that are controlled with CSS selectors like classes and data attributes, you can nest whatever selector that may be (in this example .theme-dark
) inside of the component's block, similarly to @screen
:
.btn-blue {
@apply bg-blue-100 text-blue-800;
.theme-dark & {
@apply bg-blue-700 text-white;
}
}
Responsive: While "inside" of a theme, it must be possible to "activate" classes / variants depending on the current breakpoint. For instance, it has to be possible to change background-color
when both the screen is sm
and the current theme is dark
.
Requires custom properties: Plugins who meet this description (have a ✅) usually have you write semantically named classes like bg-primary
, text-secondary
, etc, and swap out what primary
and secondary
mean with custom properties depending on the theme. This means that in IE11, themes cannot be controlled, and in some cases the default theme won't work at all without preprocessing.
Supports prefer-color-scheme
: Because any media query can be detected in JavaScript, any plugin marked as not supporting prefers-color-scheme
could "support" it by adding or removing classes or data attributes, like can be seen in the prefers-dark.js
script that some theme plugins recommend. This approach still comes with the caveats that
- JavaScriptless visitors will not have the site's theme reflect their preferred one
- It could still be possible for a flash of unthemed content to appear before the appropriate theme is activated
- Your site will immediately jump between light and dark instead of smoothly transitioning with the rest of the screen on macOS
tailwindcss-prefers-dark-mode
: cannot use selectors and media queries at the same time; it's one or the other, so you have to put a ✅ in one row and ❌ in the other.
📄 License and Contributing
MIT licensed. There are no contributing guidelines. Just do whatever you want to point out an issue or feature request and I'll work with it.
Repository preview image generated with GitHub Social Preview