Dark mode is the default. Roughly four out of five users on iOS and Android keep their device in dark mode all day, and on desktop the majority of long-form reading apps now ship dark-first. Designing a dark palette is no longer a "nice toggle in the corner" — it is your primary surface.
And that is a problem, because the playbook most teams still copy was written for a 2018 web that assumed light mode was canonical. Pure black backgrounds, low-contrast grays, candy-bright accents straight off the brand deck — none of these survive contact with an OLED screen, an outdoor walk, or a real WCAG audit.
This guide is the 2026 update. We will build a dark mode palette from scratch using six tokens, fix the four mistakes that cause 90% of "my dark theme looks ugly" tickets, and give you a copy-paste set of CSS variables you can drop into your project today.
Generate a full dark-mode-ready palette (with elevation surfaces, accent variants, and WCAG-checked text colors) in under 10 seconds.
Try the Color Palette Generator free →Forget naming things --gray-900 through --gray-50. That is a Tailwind anti-pattern in dark mode because it forces you to think about a numeric scale instead of the role each color plays. A modern dark palette uses semantic tokens:
| Token | Role | Example value |
|---|---|---|
--bg | Page background — the deepest layer | #0a0a0f |
--bg-card | Card / panel surface — one elevation up | #15151f |
--bg-elev | Inputs, hover states, popovers — two up | #1d1d2a |
--border | Subtle separators | #2a2a3a |
--text | Primary body and heading text | #f5f5fa |
--text-dim | Captions, meta, placeholder text | #a0a0b8 |
That is six tokens. Add one accent and you have a complete design system. Everything else (chips, dialogs, dropdowns) reuses these. If you find yourself reaching for a seventh background token, you almost certainly have a layering problem instead of a color problem.
Pure black sounds like the obvious choice for dark mode — and on an OLED display, it does turn pixels off, saving battery. But it also creates the highest possible contrast against white text, which causes halation (visual smearing of bright edges) for users with astigmatism, dry eye, or just tired eyes at 11pm.
Use a near-black around #0a0a0f to #0f0f15 instead. You keep most of the OLED battery savings, you keep the "premium" look, and you stop physically hurting people. Material Design 3, Apple's HIG, and IBM Carbon all moved off pure black between 2020 and 2023 for exactly this reason.
A neutral pure gray feels clinical. Tilting your background just a few degrees toward your accent (purple #0a0a0f, teal #0a0f0f, blue #0a0a14) makes the whole product feel like a deliberate brand. The shift is often invisible side by side, but in isolation it lands.
If your light-mode brand color is #7c3aed (Tailwind violet-600), that exact same hex looks radioactive on a dark background. Saturated colors gain perceived brightness on dark surfaces — a phenomenon documented all the way back in the 1839 Helmholtz–Kohlrausch effect.
The fix is to desaturate accent colors by 10–20% when you switch to dark. Better still: use HSL or OKLCH to lower lightness and saturation simultaneously. Modern CSS makes this trivial:
:root {
--accent-light: oklch(60% 0.20 295); /* light mode */
--accent-dark: oklch(70% 0.15 295); /* dark mode */
}
@media (prefers-color-scheme: dark) {
:root { --accent: var(--accent-dark); }
}
Same hue, but the dark variant is slightly lighter and noticeably calmer.
You will see this pattern everywhere: background: rgba(255,255,255,0.05) on a card to "elevate" it. It works visually, but it breaks the moment a user lands on a colored background, drags a card over a gradient, or prints the page. Worse, it makes contrast unpredictable.
Use solid elevation tokens instead. Each step up the elevation ladder is roughly +8 to +12 on each RGB channel from the previous step. The TinyTools system goes #0a0a0f → #15151f → #1d1d2a: three discrete, testable, accessibility-friendly surfaces.
Light mode pages get audited because grey-on-white is obviously hard to read. Dark mode pages get a free pass because "everything is light text on dark, must be fine." It is not.
#a0a0b8 on #0a0a0f hits a 9.4:1 contrast ratio — way above the WCAG AA threshold of 4.5:1 for body text. But #5a5a6f on #15151f? Only 2.8:1. Below AA. And that exact second pair is the placeholder color in shadcn/ui's defaults.
Test every text-on-surface combination at build time. The TinyTools SEO Meta Tag Generator includes a contrast checker, or you can run the official WebAIM contrast checker as a CI step. The web.dev accessibility guide is the definitive reference.
Your accent color is the single most important decision in the palette. It is what your brand looks like when someone screenshots a button. Three rules for picking it well in dark mode:
#f87171, not #ef4444) and a warm amber (#f59e0b, not #fbbf24) for warning and danger.#10b981) instead of lime.Drop this into the top of any CSS file and you have a working dark mode design system:
:root {
--bg: #0a0a0f;
--bg-card: #15151f;
--bg-elev: #1d1d2a;
--border: #2a2a3a;
--text: #f5f5fa;
--text-dim: #a0a0b8;
--accent: #a855f7;
--accent-glow: rgba(168, 85, 247, 0.25);
/* Semantic */
--success: #10b981;
--warning: #f59e0b;
--danger: #f87171;
}
body {
background: var(--bg);
color: var(--text);
background-image:
radial-gradient(circle at 20% 0%, var(--accent-glow), transparent 50%),
radial-gradient(circle at 80% 60%, rgba(236,72,153,0.2), transparent 50%);
}
.card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 16px; }
input, .popover { background: var(--bg-elev); border: 1px solid var(--border); }
.muted { color: var(--text-dim); }
Swap the --accent hex for whatever your brand uses and you are done. If you do not have a brand color yet, our color palette generator will produce a dark-mode-tuned version from any seed in one click.
The modern way to support both modes is the color-scheme property plus a prefers-color-scheme media query, with a manual override stored in localStorage. The web.dev color-scheme guide walks through it. Two gotchas:
color-scheme: dark light on :root so native form controls and scrollbars adapt automatically. Without this, your <select> dropdowns will be white-on-white when a user toggles dark.<head> before any stylesheet loads.Before you ship, run through this five-minute checklist:
@media print { :root { --bg: white; --text: black; } }.button, a, input) is doing the heavy lifting, not divs styled with --accent.Skip the manual color math. Pick a base color, get a full dark-mode-ready palette with elevation surfaces, accent variants, and WCAG-compliant text — exportable as CSS variables, Tailwind config, or JSON.
Try it free, no signup →A great dark mode palette is six semantic tokens, not a 50-shade gray scale. Avoid pure black, desaturate your accents, use solid colors for elevation instead of opacity, and audit every text-on-surface pair against WCAG AA. Most "ugly dark mode" complaints trace back to one of those four mistakes, and fixing them is a one-afternoon project.
If you want a head start, the TinyTools color palette generator ships these defaults out of the box. Pair it with the favicon generator (so your dark-mode favicon does not vanish in the macOS tab bar) and the OG image generator (so your share previews match your dark brand) and you have the visual identity for a SaaS in under 30 minutes.