Dark mode breaks when you use pure black #000 and pure white #fff, so I build every UI on a 4-tier system with #1f1f21 as the deepest surface.
Tier 1 is surface, tier 2 is elevation through lighter bg values not shadows, tier 3 is text with a 3-step contrast ladder, tier 4 is a single accent like lime #e3fc02.
Elevated panels use #26262a and #2d2d31 instead of box-shadow, which keeps the UI flat, fast to render, and readable on OLED screens.
Text uses #F5F5F7 for primary, #a1a1a6 for secondary, and #6e6e73 for tertiary, all passing WCAG AA against the surface tiers.
The accent appears on one element per screen maximum, usually a primary CTA, and never on body text or large fills so it keeps its signal.
Dark mode color systems fail for one reason. Designers reach for #000 and #fff and then stack box-shadows to fake depth. I ship dark UI across 14 projects on a 4-tier system that uses neither, and pages feel premium from the first load.
The system is surface, elevation, text, accent. Four tiers, locked hex values, zero exceptions.
Tier 1: Surface sets the floor at #1f1f21
The surface is the deepest color on the page. Body background, full-bleed hero, edge of the viewport. Most designers pick #000 because it sounds dark. It's wrong for two reasons.
Pure black on OLED turns pixels off. Scroll against a #000 background and you get smearing and black crush on dark UI elements. Pure black also clips any subtle elevation you try to layer on top, because there's nowhere to go darker and the next tier up has to jump several steps just to be visible.
I use #1f1f21. Close enough to black that users read it as dark mode, far enough off that #26262a reads as "slightly higher" without anyone noticing why. The value came from testing dozens of near-blacks against real content. #0a0a0a felt hostile. #1a1a1a felt flat. #1f1f21 has a faint warmth that flatters product photography, UI screenshots, and skin tones in portraits.
One rule: the surface never changes. Not for modals, not for sidebars, not for "dark theme toggle" states. If the surface shifts, every elevation above it shifts with it and the system collapses. Lock it once.
For backgrounds that need to feel even heavier (rare, mostly full-screen takeovers), I go to #18181a. That's the only exception. No blacks below that. No neutrals above it. The surface tier owns one to two values, period.
A practical check: open a screenshot of your app in a dark room. If the background "glows" against the black frame of your monitor, you're too light. If the screenshot looks like a hole cut in the display, you're at #000. The sweet spot sits between #1a1a1c and #1f1f21 depending on how warm your accents are.
Tier 2: Elevation uses lighter bg values, not shadows
This is the tier most dark modes get catastrophically wrong. They try to port light-mode elevation logic (box-shadow with blur and spread) directly onto #111 backgrounds, and the result looks smudged. Shadows need contrast to read, and there's no contrast to work with on a near-black surface.
My fix: elevation is a palette, not an effect. I use three elevated values above the surface.
Level 1 (cards, low panels): #26262a
Level 2 (modals, floating menus): #2d2d31
Level 3 (popovers, tooltips, overlays): #35353a
Each step is roughly 7 percent lighter than the one below. That's enough to read as "higher" without looking like a gradient. No shadows required. If a card needs to feel lifted, I change its background to #26262a against the #1f1f21 surface. Done.
When an elevated element sits on another elevated element (a modal with a card inside, for example), I skip a level. Modal at #2d2d31, card inside at #35353a. Never two adjacent tiers touching, because the contrast between them disappears and the inner element looks glued to the outer one.
Borders come from the next tier up, not a separate border color. A card at #26262a gets a 1px border at #2d2d31 when I need extra definition. That keeps the palette small and means the border automatically harmonizes with the surface behind it.
Shadows I still use: one. A faint #00000040 at 20px blur for dropdowns that float above content. Not for cards, not for modals, not for hover states. The rest is all background-color.
Rendering cost matters too. A box-shadow with blur recalculates on every scroll frame. Background-color doesn't. Heavy dashboards with 200 cards scroll at 60fps on a MacBook Air because I'm not painting shadows, just solid fills.
Tier 3: Text runs on a 3-step contrast ladder
Dark mode text is where WCAG violations hide. Designers pick #fff because it has maximum contrast, then wonder why long-form reading feels sharp and fatiguing. Pure white at 21:1 against near-black is physically uncomfortable after 30 seconds.
I never use #fff. Primary text is #F5F5F7. It's borrowed from Apple's system palette, passes WCAG AAA against #1f1f21 at 17.9:1, and reads as "white" to every user who's ever complained about dark mode text. The 3.5 percent warmth takes the edge off without anyone registering that it's not actually white.
The ladder:
Primary: #F5F5F7 (headings, body copy, key UI text)
Secondary: #a1a1a6 (captions, meta, form labels, timestamps)
Tertiary: #6e6e73 (disabled states, placeholders, very low-priority labels)
All three pass WCAG AA against #1f1f21. Tertiary sits right at the 4.5:1 threshold, so I reserve it for text users don't need to read, like an already-filled input label they're about to overwrite.
On elevated surfaces (#26262a, #2d2d31), I keep the same text values. The contrast ratios drop slightly (primary goes from 17.9:1 to around 15:1), still well above AAA for body copy. Not having to remap text per elevation tier saves 40 percent of the CSS I'd otherwise write for a dark theme.
Link text is the one exception. I don't color links, I underline them. Colored link text in dark mode competes with the accent tier and breaks the visual hierarchy. Underline in #F5F5F7 with a 1px offset. Users find them. Search engines find them. Nothing fights the accent.
Body copy size bottoms out at 16px. At 14px, even #F5F5F7 starts feeling thin against dark surfaces because screen subpixel rendering can't hold the stroke weight. If I need 14px, I bump the weight from 400 to 500. If I need 12px, I go up to 600. Weight compensates for what size loses.
Tier 4: The accent is one color used once per screen
The accent is where most dark mode systems detonate. A designer picks a vivid brand color, applies it to links, buttons, badges, highlights, focus rings, success states, and hover backgrounds, and now the whole UI screams. Nothing reads as primary because everything's primary.
I use one accent: #e3fc02, a high-saturation lime. It's the single loudest thing in the palette. Against #1f1f21 it hits 18.4:1 contrast, which is overkill for text but perfect for a CTA that has to win the user's attention without any surrounding help.
The rule: one accent element per screen, maximum. Usually a primary button. Sometimes a single badge if there's no CTA. Never both. Never on body text. Never as a background fill larger than 48px square. The accent earns its loudness by being rare.
Hover states don't use the accent. They adjust opacity or shift the elevation tier. A button at #e3fc02 hovers to #e3fc02 with the text gaining weight, or the button bumps from #26262a to #2d2d31 if it's a secondary action. The accent color itself stays locked.
Focus rings are the one place I'll allow the accent twice. A focused accent button has an #e3fc02 fill and a 2px #e3fc02 outline with a 2px gap. That's one visual element, even if CSS counts it as two.
For status colors (error, warning, success), I pull from a separate utility palette and desaturate hard. Error is #c43845, warning is #b89b2e, success is #3e8e56. All muted versions of their light-mode equivalents. They never share visual weight with the accent, so users subconsciously read the accent as "the action" and the status colors as "the state."
The principle underneath this tier: visual signal is a budget. Spend it once per screen on the one thing the user needs to do next. Everything else is surface, elevation, or text. I documented the full token system on the RAXXO Lab blog if you want to see how it maps to CSS variables.
Bottom Line
A 4-tier dark mode system beats an ad-hoc palette because it's decidable. Every color question resolves to "which tier does this belong to?" instead of "which shade of gray feels right?"
Start with the surface at #1f1f21. Lock it. Build elevation through background color steps (#26262a, #2d2d31, #35353a), not shadows. Run text on a 3-step ladder anchored at #F5F5F7, never #fff, with secondary and tertiary values that still clear WCAG AA. Pick one accent, use it once per screen, and don't let hover states borrow it.
Ten values total. Four tiers. Zero ambiguity. A junior designer can ship a new page on this system in an afternoon because every decision's been made. A senior designer gets out of the pixel fights and back to the actual product.
The test I run before shipping any dark UI: screenshot the page, desaturate it, and look at it for 5 seconds. If the hierarchy still reads, the system works. If it collapses into gray soup, a tier is doing the wrong job. Usually the accent's leaking into a place it shouldn't.
Top comments (0)