Guides · March 5, 2023 · 8 min read
Dark Mode for Storefronts: Tokens, Not Overrides
The reliable way to ship dark mode on a modern storefront is a two-tier token system in Tailwind — primitive colors mapped to semantic roles — switched by a single attribute, not a shadow stylesheet of dark: overrides scattered across every component.
By Polo Themes
The reliable way to ship dark mode on a modern storefront is a two-tier token system: primitive color values feeding semantic role tokens (surface, foreground, muted-foreground, border, accent), switched in one place by a root attribute or class. If you instead sprinkle dark: variants across every component file, you get a storefront that looks fine in a demo and slowly rots as every new component reintroduces the same color decisions from scratch. This guide walks through building the token layer in Tailwind CSS v4, wiring the theme switch correctly, and the specific traps that make product images, price badges, and third-party embeds look wrong in dark mode.
Why "Just Add dark: Classes" Fails at Storefront Scale
A single component with a dark:bg-gray-900 utility next to bg-white looks like a two-minute fix. A storefront has a product grid, a cart drawer, a PDP gallery, a checkout form, marketing banners, and usually a handful of embedded widgets — reviews, size guides, chat. Multiply that one utility pair across a few hundred call sites and you have a maintenance problem: every new component needs someone to remember the correct dark-mode counterpart for every color decision, and there is no single place to audit whether the palette is even consistent. Six months in, you will find three different near-black surface colors and two different muted-text grays, none of them chosen deliberately — just whatever looked "close enough" when that component was built.
The deeper problem is conceptual. bg-white and dark:bg-gray-900 describe a literal color, not a role. When a designer wants to darken the "card" background across the whole site by one step, or introduce a distinct "elevated surface" tone for modals, there is no token to change — you are back to a find-and-replace across the codebase, hoping you catch every instance. Tokens fix this by naming the role once and letting every component reference the name. The color values behind that name become a single-file decision instead of a codebase-wide one.
The Two-Tier Token Model
Split your palette into two layers. The primitive tier is your raw color scale — the actual hex or OKLCH values, named by what they are (gray-950, blue-600, amber-400). The semantic tier is a much smaller set of role names that map to primitives and that components actually consume — surface, surface-elevated, foreground, muted-foreground, border, accent, accent-foreground, destructive, and a handful more depending on your product surface. Components should never reference a primitive directly; they reference the role. This is the single rule that makes dark mode tractable, because flipping the theme means reassigning which primitives back each role — the component tree does not change at all.
- Primitives are theme-agnostic. A gray scale, an accent scale, a destructive/success scale. No component imports these directly.
- Semantic roles are theme-aware. surface might be gray-50 in light mode and gray-950 in dark mode — same role name, different backing value per theme.
- Components consume only semantic roles: bg-surface, text-foreground, border-border, text-muted-foreground. Never raw gray-* utilities, never arbitrary [#0f0f10] values.
Setting It Up in Tailwind v4
Tailwind v4 moved theme configuration into CSS itself via the @theme directive, which is a good fit for this pattern because it lets you define semantic tokens as real CSS custom properties rather than JS config that gets compiled away. Define your primitives and semantic tokens in your global stylesheet, then override the semantic layer's custom properties inside a dark-mode selector.
A minimal shape looks like this: declare CSS variables for each semantic role at :root (the light-mode values), declare an @theme block that maps Tailwind's color scale (--color-surface, --color-foreground, etc.) to those variables, and then add a second block — scoped to [data-theme="dark"] or a .dark class on html — that reassigns the same variables to dark-mode values. Because Tailwind's generated utilities (bg-surface, text-foreground) reference the CSS variable rather than a static value, no utility class needs to change between themes. Only the variable definitions do.
This is also why an attribute selector (data-theme="dark") tends to work better than relying solely on Tailwind's built-in dark: variant driven by prefers-color-scheme. You still want to respect the OS preference as the default, but a storefront almost always needs a manual toggle too — shoppers who want dark mode regardless of OS setting, or a merchant who wants to force one theme for brand reasons on a given page. Wire the attribute so it can be set three ways: unset (follow prefers-color-scheme via a media query fallback), explicitly "dark", or explicitly "light" — and let a small script read/write localStorage plus set the attribute before first paint to avoid a flash of the wrong theme.
Avoiding the flash of unstyled theme
The classic dark-mode bug is a visible flash of the light theme before JavaScript hydrates and flips the attribute. Fix this by running a tiny inline script in the document head — before any stylesheet or hydration — that reads the stored preference (or the media query) and sets the data-theme attribute synchronously. This has to be a plain, blocking inline <script>, not a component that mounts after hydration, because the goal is to have the attribute correct before the browser paints anything. In a Next.js App Router setup this typically lives directly in the root layout's <head>, not inside a client component.
Mapping Roles That Actually Cover a Storefront
A blog or dashboard can often get away with four or five roles. A storefront needs a slightly wider vocabulary because commerce UI has more distinct surface types than most apps: product cards sit on a page background, but modals and drawers usually want a visually distinct "elevated" surface, and sale badges, stock warnings, and price call-outs need their own accent roles that survive the theme switch without turning muddy.
- background / surface — page background vs. card/panel background. Keep these close but distinct so cards read as raised, even subtly, in both themes.
- surface-elevated — modals, drawers, popovers, toasts. In dark mode this is often a step *lighter* than surface, not darker — pure black stacked panels look muddy and lose depth cues.
- foreground / muted-foreground — primary text vs. secondary text (prices' fine print, "in stock" labels, timestamps). The muted tone needs enough contrast to pass WCAG AA in both themes independently — do not assume a ratio that works in light mode survives the dark palette unchanged.
- border — dividers, input outlines, card edges. Borders are the token most often forgotten; a card with correct surface and text colors but a leftover border-gray-200 will look visibly broken in dark mode.
- accent / accent-foreground — primary CTA buttons, active nav states, focus rings. Brand accent colors frequently need a small hue or lightness adjustment for dark backgrounds to avoid looking either washed out or overly saturated.
- destructive / success / warning — out-of-stock, sale, and low-stock indicators. These need dedicated dark-mode-safe variants; a red that reads as an error on white can look alarmingly neon on near-black.
The Three Places Dark Mode Actually Breaks on a Storefront
Token discipline solves the UI chrome. The remaining failures on real commerce sites are almost always in three specific places, and they are worth testing deliberately rather than assuming the token system covers them.
Product photography on dark surfaces
Product images shot on white backgrounds — extremely common in ecommerce — look fine on a light page and visually harsh, almost like a cutout, on a dark one. There is no CSS token fix for this; it is a content decision. Options include a subtle neutral card background behind product images regardless of theme (so the product never sits directly on the page's darkest surface), or, for stores with control over photography, shooting on a light gray rather than pure white so the transition into a dark-mode card frame is less jarring. Do not try to "fix" this by filtering or inverting images — it degrades product accuracy, which matters more in commerce than almost anywhere else.
Price and sale badges
Sale badges and price strike-throughs are usually styled with a hardcoded red or green early in a project, before dark mode is a consideration, and then never revisited. Because they carry meaning (discount, availability) rather than pure decoration, they need to be threaded through the semantic token system just like everything else — a destructive or sale-accent role with its own light/dark values — rather than left as one-off utility colors. Audit these specifically; they are the components most likely to have been built before the token system existed.
Third-party embeds and checkout iframes
Reviews widgets, live chat, size-guide modals, and payment iframes (many checkout and address-autocomplete widgets render in an iframe you do not control) frequently ship their own light-only theme and will not pick up your CSS variables at all — an iframe is a separate document. Check each third-party integration for a documented dark-mode or "theme" configuration option; most mature review and chat vendors expose one via a script parameter or data attribute. For the ones that do not, the practical fix is often a neutral, semi-transparent container around the widget so the seam between your dark storefront and a light embedded panel reads as an intentional card rather than a rendering bug.
Testing the System, Not Just the Toggle
Clicking the dark-mode toggle and eyeballing the homepage is not a real test. Build a small internal "token preview" page — literally a grid of every semantic role rendered as a swatch with its name — and check it in both themes every time you touch the palette. This catches the two most common regressions: a role accidentally left pointing at a primitive that does not exist in the dark palette (which Tailwind will usually just render as transparent or fall back silently), and contrast failures on muted text, which are easy to miss visually but fail accessibility checks. Run an automated contrast check (axe, Lighthouse, or a dedicated contrast checker) against both themes independently — passing in light mode says nothing about the dark palette.
It is also worth testing the PDP, cart drawer, and checkout form specifically rather than trusting that "the homepage looks right" generalizes. These are the pages with the densest mix of roles — surfaces stacked on surfaces (product card inside a page, modal inside that), form inputs with borders and focus states, and the accent-colored CTA that has to remain legible and on-brand in both themes. If those three surfaces hold up, the rest of the site typically follows, because they exercise nearly every role in the system at once.
Where This Fits if You're Building Headless
Everything above applies whether the storefront is a traditional themed platform or a headless Next.js build on top of a commerce backend. The token layer lives in your CSS regardless of what's rendering the page — Tailwind's @theme directive and a data-theme attribute work identically in an App Router project pulling from Medusa, Shopify's Storefront API, or any other headless source. If you are designing the palette before development starts, doing it in Figma with the same two-tier naming (primitive styles feeding semantic styles) keeps the handoff to code a rename rather than a redesign; our Figma UI kits are built around that same primitive/semantic split so design tokens and Tailwind tokens stay in lockstep instead of drifting into two parallel naming systems that someone has to reconcile by hand later.
Frequently Asked Questions
Should dark mode default to the OS preference or a manual toggle?
Default to prefers-color-scheme on first visit, then let an explicit toggle override it and persist that choice in localStorage. Storefronts see enough shoppers who want a fixed preference regardless of device settings that a toggle-only or OS-only approach each leave a real segment of users unhappy.
Do I need different primitive color scales for light and dark, or can I reuse one scale?
You can usually reuse one gray and one accent scale for both themes — the semantic layer is what changes, by pointing roles at different steps of the same scale (e.g. surface = gray-50 in light, gray-950 in dark). Separate scales are only worth the added complexity if your brand accent needs a genuinely different hue adjustment to stay legible on dark backgrounds, which happens more often with saturated brand colors than neutral ones.
What's the single most common dark-mode bug on commerce sites?
Forgotten borders. Surfaces and text usually get themed because they are visually obvious when wrong; a light-mode-only border color on a card or input is subtle enough to slip through review and only becomes visible once you specifically look for it against a dark background.
Does adding dark mode hurt performance?
Not meaningfully if it is implemented as CSS custom properties switched by an attribute — there is no extra JavaScript bundle or duplicated stylesheet, just a second set of variable assignments scoped to a selector. The only performance risk is the flash-of-wrong-theme problem, which is a correctness bug, not a performance one, and is fixed by the blocking inline script described above.