{"id":276,"date":"2026-05-25T05:04:03","date_gmt":"2026-05-25T05:04:03","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/tailwind-v4-dark-mode-from-config-to-one-css-line\/"},"modified":"2026-05-25T05:04:03","modified_gmt":"2026-05-25T05:04:03","slug":"tailwind-v4-dark-mode-from-config-to-one-css-line","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/tailwind-v4-dark-mode-from-config-to-one-css-line\/","title":{"rendered":"Tailwind v4 Dark Mode: From darkMode Config to One CSS Line"},"content":{"rendered":"<p>My designer pinged me on a Friday afternoon: &lsquo;dark mode looks broken on the pricing page.&rsquo; It was not broken, exactly. It was stuck. I had upgraded that project to Tailwind v4 a week earlier, and my old <code>darkMode: 'class'<\/code> line was sitting in a <code>tailwind.config.js<\/code> file that v4 was not really reading anymore. Every <code>dark:<\/code> utility had quietly stopped responding to the theme toggle. I had not noticed, because I had been testing in light mode like a professional.<\/p>\n<p>That afternoon sent me into how dark mode actually works in Tailwind v4. The short version: it got simpler, and the simplicity is the part that trips you up. There is no <code>darkMode<\/code> key to set. The behavior you want now lives in your CSS, in one line. Here is the whole thing, including the part nobody warns you about.<\/p>\n<h2 id=\"how-dark-mode-worked-before-v4\">How dark mode worked before v4<\/h2>\n<p>In Tailwind v3, dark mode was a config decision. You opened <code>tailwind.config.js<\/code> and picked a strategy:<\/p>\n<pre><code class=\"language-js\">\/\/ tailwind.config.js  (Tailwind v3)\nmodule.exports = {\n  darkMode: &quot;class&quot;, \/\/ or &quot;media&quot;\n  content: [&quot;.\/src\/**\/*.{html,js,jsx,ts,tsx}&quot;],\n  theme: { extend: {} },\n};\n<\/code><\/pre>\n<p><code>\"media\"<\/code> meant <code>dark:<\/code> utilities followed the operating system through the <code>prefers-color-scheme<\/code> media query. <code>\"class\"<\/code> meant they activated whenever a <code>.dark<\/code> class appeared higher in the tree, which is what you needed for a manual toggle. You set it once and forgot about it for the life of the project.<\/p>\n<p>v4 broke that habit, because v4 moved configuration out of JavaScript and into CSS. If you have already been through that migration, I wrote up <a href=\"https:\/\/abrarqasim.com\/blog\/migrating-to-tailwind-v4-what-broke-what-i-wish-id-known\/\" rel=\"noopener\">what broke when I moved to Tailwind v4<\/a>, and dark mode was not the only surprise waiting in there.<\/p>\n<h2 id=\"the-v4-way-a-custom-variant-in-your-css\">The v4 way: a custom variant in your CSS<\/h2>\n<p>In v4, the default is <code>prefers-color-scheme<\/code>. Import Tailwind, and <code>dark:<\/code> utilities already follow the OS. No config, nothing to switch on.<\/p>\n<p>The catch is that the default is all you get for free. The moment you want a manual toggle, a button that flips the theme regardless of the OS, you override the <code>dark<\/code> variant yourself. That happens in your CSS entry file, with <code>@custom-variant<\/code>:<\/p>\n<pre><code class=\"language-css\">\/* app.css *\/\n@import &quot;tailwindcss&quot;;\n@custom-variant dark (&amp;:where(.dark, .dark *));\n<\/code><\/pre>\n<p>That one line is the whole replacement for <code>darkMode: 'class'<\/code>. It redefines what <code>dark:<\/code> means. Instead of &ldquo;when the OS is dark&rdquo;, it now means &ldquo;when a <code>.dark<\/code> class exists on this element or an ancestor&rdquo;. The <code>:where()<\/code> wrapper holds specificity at zero, so your utilities still override cleanly.<\/p>\n<p>Your markup does not change at all:<\/p>\n<pre><code class=\"language-html\">&lt;html class=&quot;dark&quot;&gt;\n  &lt;body&gt;\n    &lt;div class=&quot;bg-white dark:bg-black&quot;&gt;...&lt;\/div&gt;\n  &lt;\/body&gt;\n&lt;\/html&gt;\n<\/code><\/pre>\n<p>The official <a href=\"https:\/\/tailwindcss.com\/docs\/dark-mode\" rel=\"nofollow noopener\" target=\"_blank\">Tailwind dark mode docs<\/a> cover this, but it is easy to skim past. It is one line on a page you read once, and then forget you read.<\/p>\n<p>One thing worth knowing before you sprinkle <code>dark:<\/code> across every component: in v4 you can often skip it. If your colors live as theme variables, you can redefine those variables once under the <code>.dark<\/code> selector, in CSS, and most components just follow along. Writing <code>dark:bg-black<\/code> on every card works, but it scatters the theme across your markup. Redefining a <code>--color-surface<\/code> token under the dark selector keeps the decision in one file. I still use <code>dark:<\/code> for real one-off tweaks, but the variable approach is what keeps a large UI consistent without me chasing stray colors.<\/p>\n<h2 id=\"class-data-attribute-or-media-query\">Class, data attribute, or media query<\/h2>\n<p>Three setups, and the choice is mostly about taste, not performance.<\/p>\n<p>The class strategy above is what I reach for most. It is terse, and it is what nearly every Tailwind tutorial and component library assumes you are using.<\/p>\n<p>If you would rather not put a state class on <code>&lt;html&gt;<\/code>, use a data attribute instead:<\/p>\n<pre><code class=\"language-css\">@import &quot;tailwindcss&quot;;\n@custom-variant dark (&amp;:where([data-theme=&quot;dark&quot;], [data-theme=&quot;dark&quot;] *));\n<\/code><\/pre>\n<p>Now <code>dark:<\/code> responds to <code>data-theme=\"dark\"<\/code>. I like this one on projects that already drive theming through data attributes, because the theme then lives in the same place as every other piece of UI state.<\/p>\n<p>And if a site only ever needs to follow the OS, do nothing. The v4 default already does that. I have shipped marketing sites where the entire &ldquo;dark mode feature&rdquo; was zero lines of code, and zero was the correct amount.<\/p>\n<h2 id=\"the-flash-of-wrong-theme-problem\">The flash-of-wrong-theme problem<\/h2>\n<p>Here is the part nobody warns you about, and the real cause of my Friday bug once the dead config line was sorted.<\/p>\n<p>If you toggle dark mode with a class, something has to put that class on <code>&lt;html&gt;<\/code>. If you do that in a normal React effect, or a script at the end of <code>&lt;body&gt;<\/code>, the page first paints in light mode and then snaps to dark a moment later. That flash looks broken, and on a slow phone it is more than a moment.<\/p>\n<p>The fix is to set the class before the browser paints, with a small blocking script in the <code>&lt;head&gt;<\/code>:<\/p>\n<pre><code class=\"language-html\">&lt;head&gt;\n  &lt;script&gt;\n    document.documentElement.classList.toggle(\n      &quot;dark&quot;,\n      localStorage.theme === &quot;dark&quot; ||\n        (!(&quot;theme&quot; in localStorage) &amp;&amp;\n          window.matchMedia(&quot;(prefers-color-scheme: dark)&quot;).matches)\n    );\n  &lt;\/script&gt;\n&lt;\/head&gt;\n<\/code><\/pre>\n<p>It reads the saved preference, falls back to the OS through <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/Window\/matchMedia\" rel=\"nofollow noopener\" target=\"_blank\"><code>window.matchMedia<\/code><\/a>, and sets the class synchronously. Yes, it is a blocking inline script, and yes, that is fine here. It runs in well under a millisecond, and it is the price of never showing the wrong theme. Every theme library you might reach for is doing some version of this same trick under the hood.<\/p>\n<h2 id=\"a-three-way-toggle-that-respects-the-os\">A three-way toggle that respects the OS<\/h2>\n<p>Most real apps want three states, not two: light, dark, and &ldquo;whatever the OS says&rdquo;. The pattern that has held up for me stores an explicit choice in <code>localStorage<\/code> only when the user makes one, and treats the absence of a value as &ldquo;follow the OS&rdquo;.<\/p>\n<pre><code class=\"language-js\">\/\/ user picks light\nlocalStorage.theme = &quot;light&quot;;\n\n\/\/ user picks dark\nlocalStorage.theme = &quot;dark&quot;;\n\n\/\/ user picks &quot;system&quot;: remove the override entirely\nlocalStorage.removeItem(&quot;theme&quot;);\n<\/code><\/pre>\n<p>The head script above then does the right thing in all three cases, because it checks <code>localStorage<\/code> first and only consults <code>matchMedia<\/code> when there is no stored override. The mistake I made early was writing the literal string <code>\"system\"<\/code> into <code>localStorage<\/code>. It works, but then you are hand-maintaining three string states and all the comparisons that go with them. Removing the key is cleaner: a value present means an explicit choice, a value absent means inherit from the OS.<\/p>\n<p>There is one more property worth setting that has nothing to do with Tailwind variants: <code>color-scheme<\/code>. Telling the browser <code>color-scheme: dark<\/code> makes it render native UI, scrollbars, form controls, date pickers, and the rest, in their dark variants. Without it you end up with a carefully themed dark page wearing a glaringly white scrollbar. Tailwind ships <code>scheme-dark<\/code> and <code>scheme-light<\/code> utilities for exactly this. I forgot it on two separate projects before the habit finally stuck.<\/p>\n<h2 id=\"what-i-would-change-this-week\">What I would change this week<\/h2>\n<p>If you are on Tailwind v4 already, open your CSS entry file and check for a <code>@custom-variant dark<\/code> line. If it is not there and you have a theme toggle, that toggle is not working the way you think it is. That was my exact bug. Add the line, and the toggle starts responding again.<\/p>\n<p>If you are still on v3, dark mode by itself is not a reason to upgrade. It is a fair preview of how v4 feels, though: less configuration, and more of the behavior sitting in plain CSS where you can actually see it. I keep notes on the front-end setups I ship over on <a href=\"https:\/\/abrarqasim.com\" rel=\"noopener\">my portfolio<\/a>, and the pattern repeats everywhere, fewer config files and more defaults I can read at a glance. Dark mode in v4 is one line. Spend the hour you saved on picking colors that pass a real contrast check.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Tailwind v4 dropped the darkMode config key. Here is how dark mode works now: one CSS custom-variant line, the theme-flash fix, and a real system toggle.<\/p>\n","protected":false},"author":2,"featured_media":275,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"Tailwind v4 dropped the darkMode config key. Here is how dark mode works now: one CSS custom-variant line, the theme-flash fix, and a real system toggle.","rank_math_focus_keyword":"tailwind dark mode","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[138,35],"tags":[37,135,38,139,36,134],"class_list":["post-276","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-frontend","category-web-development","tag-css","tag-dark-mode","tag-frontend","tag-tailwind","tag-tailwind-css","tag-tailwind-v4"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/276","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=276"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/276\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/275"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=276"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=276"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=276"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}