Dark mode is not a second color palette, it is the same page in different light. Swapping black and white alone gives you grey slabs, invisible edges and images that glow out of the page. This guide shows how to build the dark mode from roles, how to switch it without a flash and which places almost always break.
- Do not switch colors, switch roles. Background, surface, text and border each get one value per mode.
- Pure black is rarely right. A very dark grey feels calmer and lets surfaces become visible in the first place.
- Shadows do not work in the dark. Depth comes from lighter surfaces there, not from darker edges.
- The system setting is the default, the user's choice overrides it. Both together need one line in the head, otherwise the page flashes on load.
Roles instead of color values
The most common mistake is already in the markup: every element carries two colors, one for light and one for dark. With twenty components that is forty decisions nobody keeps in sync.
A layer in between is cleaner. You name what a color is for, not which one it is: base for the page, surface for everything sitting on it, text for the type, border for separation. These roles get one value per mode, and the components only know the role.
The payoff shows up with the next request. If the dark mode should get a touch warmer, you change four values in one place instead of forty spread through the code.
- primaryPrimary#1f5eff
- surfaceSurface#f8fafc
- textText#0f172a
- borderBorder#e2e8f0
On the left many individual color values, on the right four roles that carry the mode.
The setup in Tailwind
In Tailwind the roles live in CSS variables, and dark mode overrides exactly those variables. The classes in the markup stay untouched, because bg-surface means the same in both modes, only the value behind it changes.
There are two ways to switch. The system setting is read through the prefers-color-scheme query, an explicit choice hangs on a class on the html element. If you offer both, you define the values twice: once in the query, once on the class.
One detail gets forgotten often: color-scheme. It tells the browser how to render scrollbars, select fields and inputs. Without it a few brightly lit controls stay behind in the middle of your dark page.
:root { color-scheme: light; --color-base: #ffffff; --color-surface: #f5f5f4; --color-text: #1c1917; --color-border: #e7e5e4;} @media (prefers-color-scheme: dark) { :root:not(.light) { color-scheme: dark; --color-base: #0c0a09; --color-surface: #1c1917; --color-text: #e7e5e4; --color-border: #292524; }}
Switching without a flash
If the saved choice only runs after React has loaded, the browser paints a light page first. The switch after that is visible as a short flash, and that is exactly how you spot a dark mode bolted on later.
The fix is unspectacular: a short script in the head that reads the saved choice and sets the class on the html element before the first pixel appears. It runs without a framework, without imports, and needs fewer than ten lines.
The toggle itself then knows three states: light, dark and follow the system. The third one belongs there, because many devices switch by themselves in the evening, and a one-off choice should not turn that off.
Few roles beat many values.
A list of hex numbers is an inventory. It becomes a system only once every value has a job.
What breaks in the dark
Colors are the easy part. A dark mode gets noticed in the places nobody thought of as color: images with a white background, shadows, logos saved as black files and charts with a light grid.
Images need either a transparent background or a frame that ties them into the page. A photo may stay bright, a logo may not: for logos and icons a second version that follows the text color instead of a fixed one is worth the effort.
- Pure black as the base. Every surface on it looks like a hole and borders disappear.
- The same shadow as in light mode. You cannot see it, the card sticks to the background.
- White type in a thin weight. It blooms on a dark ground and gets hard to read.
- Very dark grey as the base, the surfaces on top one step lighter.
- Depth through brightness: whatever sits in front is lighter, not outlined darker.
- Off-white for text and a weight that is not too thin.
Check before you ship
Dark mode rarely fails while you build it. It fails in the states you do not look at while building.
- Forms in every state. Empty, filled, with an error, disabled and focused. The focus ring in particular gets lost in the dark, because it has the same brightness as the border next to it.
- Everything that sits above the page. Menus, dialogs and toasts often carry their own surface in the code. If one of them was forgotten, it lights up brightly on the dark page.
- The first visit in a private window. Without a saved choice the system setting applies. That is the only way to see what new visitors actually get.
- The print view. A dark background becomes a black page on paper. A small print rule saves toner and trouble.
A good dark mode does not stand out. It just feels like the page was always meant that way.




