How to Add Dark Mode to an AI-Built App
A working dark mode needs more than a black background class. Here's the CSS variable structure, the persistence logic, and the exact prompt that stops an AI agent from missing half your components.
Your app loads and for a fifth of a second the screen is pure white before it snaps to the dark background the user actually picked. That flash is the most common failure when teams figure out how to add dark mode to an AI-built app, and it happens because the theme gets applied after the page has already painted. Fixing that, along with picking a theming approach that does not quietly break every third component, is what separates a real implementation from a toggle that only changes the body background. This piece covers the CSS variable structure, Tailwind's class strategy, how to persist the choice, and exactly how to brief an AI coding agent so it handles the whole app instead of one div.
How to Add Dark Mode to an AI-Built App: Pick CSS Variables, Not Two Stylesheets
Before any toggle logic, decide how colors are stored. There are two real options, and one of them will cost you hours every time you touch the design.
Option one is maintaining a full light stylesheet and a full dark stylesheet. This sounds simple and immediately rots. Every new component needs its colors written twice, and the two versions drift apart within a few weeks because someone tweaks one and forgets the other.
Option two, and the correct one, is dark mode CSS variables: define tokens once, like --color-bg, --color-text, --color-border, --color-accent, on :root, then override just those tokens under a [data-theme="dark"] selector or a .dark class on html. Every component references var(--color-bg) instead of a hardcoded hex value or a fixed Tailwind color. Flip the attribute, and the whole app repaints, because you changed the tokens, not the components.
This also matters for anything outside your component tree: box-shadows, focus rings, scrollbar styling, and SVG icons using currentColor or a fill tied to a variable. If those are hardcoded, they will look wrong in dark mode no matter how correct your buttons are.
Tailwind's dark: Class Strategy
If you're on Tailwind, you get a shortcut on top of CSS variables. Tailwind ships two dark mode strategies, set via darkMode in tailwind.config. The media strategy follows prefers-color-scheme automatically with zero JavaScript, but it cannot be overridden by a user toggle. The class strategy applies dark styles only when a .dark class is present on html, which means your app controls it explicitly.
For a real toggle, use the class strategy. Then dark:bg-slate-900 dark:text-slate-100 utilities apply whenever .dark is set, and you still add CSS variables underneath for anything Tailwind's utilities do not reach: chart libraries, canvas elements, PDF previews, and third-party embeds that render their own markup. Tailwind handles your components; variables handle everything Tailwind doesn't touch.
How to Prevent the Flash of Wrong Theme
Here is the actual bug from the opening. If your theme is set by a React useEffect, a client-side script tag at the bottom of body, or any logic that runs after first paint, the browser renders the default theme first and then swaps it. That swap is visible, and it's worse on slow connections or low-end phones where the swap can take a noticeable beat.
The fix is a small blocking script placed directly in head, before any stylesheet or framework bundle loads. It runs synchronously, reads the stored preference, and sets the theme attribute on document.documentElement before the browser paints anything. Something like: check localStorage.getItem('theme'); if it's "light" or "dark", use it; otherwise check window.matchMedia('(prefers-color-scheme: dark)').matches and use that. Then call document.documentElement.setAttribute('data-theme', theme).
Because this runs inline and before render, there's nothing to flash. In Next.js or other frameworks with server rendering, you'll also need suppressHydrationWarning on the html tag, since the server doesn't know the client's stored preference and the attribute gets set client-side before hydration reconciles.
Save the User's Theme Preference the Right Way
There are really three states, not two: light, dark, and "system," where system means follow the OS and update automatically if the OS changes. Most apps default to system and let users override it explicitly.
To save user theme preference correctly:
Store the explicit choice in
localStorageunder a clear key, such astheme-preference, with a value oflight,dark, orsystem.On load, read that key first. Only fall back to
prefers-color-schemewhen nothing is stored, meaning a first-time visitor.If the stored value is
system, attach amatchMediachange listener so the theme updates live if the user changes their OS setting without ever opening your app's toggle.If the user picks light or dark explicitly, stop listening to system changes until they switch back to "system."
This is also where teams get sloppy: they save the resolved color ("dark") instead of the user's intent ("system"), which means a user who wanted automatic switching gets stuck on whatever theme their OS happened to be in when they last visited.
How to Brief an AI Agent So It Doesn't Just Hardcode a Black Background
Most teams learn how to add dark mode to an AI-built app the hard way: they ask for "dark mode" with no further detail and get exactly what that phrasing implies: a class that sets background: black and color: white on the body, maybe a toggle button, and nothing else touched. Modals still have white backgrounds. Dropdowns render invisible white-on-white text. Code blocks, chart tooltips, and any image with baked-in white padding stay exactly as they were.
The fix is being specific about mechanism, not just outcome. A prompt that gets a complete result looks something like this:
"Implement dark mode using CSS custom properties defined on :root and overridden under [data-theme=\"dark\"], not hardcoded colors inside individual components. Every component currently using a fixed hex value or a light-only Tailwind color class should reference the shared variable or add a dark: variant instead. Add a blocking script in the document head that sets the theme attribute before first paint, so there's no flash of the wrong theme on load. Persist the user's choice in localStorage, default to the OS prefers-color-scheme when nothing is stored, and support light, dark, and system as explicit options. When you're done, list every file you modified and confirm modals, dropdowns, tooltips, and any chart or table components were included."
That last sentence does real work. Asking the agent to enumerate touched files forces it to actually check the modal component and the chart wrapper instead of stopping once the homepage looks right. If the file list is suspiciously short, ask it directly whether the design system's shared components, third-party widget wrappers, and image assets were reviewed for hardcoded colors.
Theming is one small piece of the larger picture in how to build an app with AI. The same component audit that catches hardcoded colors is worth repeating when adding an admin dashboard to an AI-built app, since dashboards tend to accumulate their own one-off styles fast.
Dark mode also overlaps with accessibility more than people expect: contrast ratios that pass in light mode can fail in dark mode, which is one more reason for making an AI-built app accessible to be a standing checklist item. And if you are pairing this work with an agent, choosing between a terminal or IDE coding agent affects how easily you can spot-check every component it touched.
FAQ
Does dark mode need JavaScript, or can CSS alone do it?
Pure CSS handles automatic, OS-driven dark mode with the prefers-color-scheme media query and no JavaScript at all. But a user-toggleable preference that overrides the OS and persists between visits requires JavaScript to read and write localStorage and to set the theme attribute before render. CSS alone can't remember a manual choice.
Why does dark mode still flash white even though I added a toggle?
Because the toggle logic almost certainly runs after the browser's first paint, typically inside a framework lifecycle hook. The browser paints the default theme, then your script runs and swaps it, and that swap is what you're seeing. The fix is a synchronous blocking script in head that sets the theme before any CSS renders.
Should I store the theme in localStorage or a cookie?
localStorage is simpler and works fine for client-rendered apps. Use a cookie instead if your server renders the initial HTML and you want the server to know the user's theme so it can output the correct attribute directly, avoiding any flash even on the very first server-rendered response.
What's the real difference between Tailwind's class and media dark mode strategies?
The media strategy follows the OS automatically with zero setup but offers no manual toggle. The class strategy is controlled entirely by your JavaScript adding or removing a class, which lets you build an explicit toggle and, combined with a stored "system" preference, still fall back to automatic behavior when the user hasn't chosen.
Will dark mode break charts, logos, or embedded widgets?
Usually, yes, unless you handle them on purpose. Charting libraries and canvas elements don't read CSS variables automatically; you have to pass the current theme's colors into their configuration directly. Logos or screenshots with a white background baked into the image file need a separate dark-friendly version. This is exactly the kind of gap that shows up when an AI agent's dark mode work stops at the component library.
How did this land?
About the author

Developer Advocate
Steve builds something with Swarmz every week and writes up what worked, what broke, and what he'd do differently. Tutorials and hands-on guides are his lane.


