#0F172A |
#00F5D4 |
#F15BB5 |
#9B5DE5 |
|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
π¨ Palette in play β Neon Diner Harmony Β·
#0F172A#00F5D4#F15BB5#9B5DE5
Four hex codes is not a theme. A theme is four hex codes that survived a rename, a framework, a dark mode toggle and a designer asking "can we make the accent 10% lighter everywhere?"
Β
This post is the pipeline I run every time I pull a palette from anywhere. Same four colors, six formats, and the important part β the naming step in the middle that makes all the rest boring.
Β
π The pipeline
$ palette β raw vars β semantic names β framework config β tokens.json β dark mode
1 min 10 sec 2 min 1 min 30 sec auto
| Swatch | HEX | On #0F172A
|
Grade | Job |
|---|---|---|---|---|
#0F172A |
β | β | surface | |
#00F5D4 |
12.76:1 | β AAA | primary | |
#F15BB5 |
5.87:1 | β AA | accent | |
#9B5DE5 |
4.33:1 | π‘ AA Large | support |
Β
1οΈβ£ Layer one β the raw palette
Write the raw palette down exactly once. Copy it verbatim and do not touch it again. It is your receipt: it says exactly which palette this theme came from.
Β
styles/palette.css β raw, never referenced by components
/* Palette: midnight, mint, magenta, violet
*/
:root {
--palette-color-1: #0F172A;
--palette-color-2: #00F5D4;
--palette-color-3: #F15BB5;
--palette-color-4: #9B5DE5;
}
Β
2οΈβ£ Layer two β the semantic layer
This is where the value is. Raw hexes describe what a color is; semantic tokens describe what it does. Components only ever see layer two, which is why swapping palettes later costs one file instead of forty.
Β
styles/theme.css β the only layer components import
:root {
/* surfaces */
--color-surface: var(--palette-color-1);
--color-surface-alt: #16213E;
--color-elevated: #1B2740;
/* text */
--color-ink: #F8FAFC;
--color-ink-muted: #94A3B8;
/* brand */
--color-primary: var(--palette-color-2);
--color-primary-ink: #04231D; /* text ON primary */
--color-accent: var(--palette-color-3);
--color-support: var(--palette-color-4);
/* state */
--color-focus: var(--palette-color-2);
--color-danger: #FF4D6D;
/* elevation + motion, so the theme owns the whole feel */
--shadow-glow: 0 0 0 1px color-mix(in srgb, var(--color-primary) 40%, transparent),
0 8px 30px -12px var(--color-primary);
--radius: 12px;
}
π§ͺ Lab note β
--color-primary-inkis the token people forget. Every brand color needs a partner that is readable on top of it.#00F5D4is bright: white text on it is 1.4:1 (invisible), while#04231Don it is 11.88:1. Store the answer once instead of rediscovering it in every button.
Β
3οΈβ£ Layer three β the component layer
Components consume semantics and nothing else. Notice there is not a single hex below.
Β
components/button.css β zero hardcoded colors
.btn {
background: var(--color-primary);
color: var(--color-primary-ink);
border: 0;
border-radius: var(--radius);
padding: 0.75rem 1.5rem;
font-weight: 650;
transition: filter 150ms ease, box-shadow 150ms ease;
}
.btn:hover { filter: brightness(1.08); box-shadow: var(--shadow-glow); }
.btn:focus-visible {
outline: 3px solid var(--color-focus);
outline-offset: 3px;
}
.btn--accent { background: var(--color-accent); color: #1A0B12; }
.btn--support { background: var(--color-support); color: #FFFFFF; }
.btn--ghost {
background: transparent;
color: var(--color-primary);
box-shadow: inset 0 0 0 1.5px currentColor;
}
Here is the refactor that usually happens at this point, in the format that makes it obvious:
.pricing-card__badge {
- background: #F15BB5;
- color: #ffffff;
- border: 1px solid #9B5DE5;
- border-radius: 12px;
+ background: var(--color-accent);
+ color: #1A0B12;
+ border: 1px solid var(--color-support);
+ border-radius: var(--radius);
}
Two wins in four lines: the palette became swappable, and the white-on-pink text (3.04:1 β a fail for body copy) became near-black on pink at 6.27:1.
Β
4οΈβ£ Tailwind, SCSS, and everything else
Same four colors, wearing different clothes.
Β
tailwind.config.js β semantic names all the way down
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{html,js,jsx,ts,tsx,vue,svelte}'],
theme: {
extend: {
colors: {
surface: { DEFAULT: '#0F172A', alt: '#16213E', elevated: '#1B2740' },
primary: { DEFAULT: '#00F5D4', ink: '#04231D' },
accent: '#F15BB5',
support: '#9B5DE5',
},
boxShadow: {
glow: '0 8px 30px -12px #00F5D4',
},
},
},
};
<button class="bg-primary text-primary-ink hover:shadow-glow rounded-xl px-6 py-3 font-semibold">
Deploy
</button>
π SCSS, Styled Components, CSS-in-JS and Sass maps
// _palette.scss β Neon Diner Harmony
$surface: #0F172A;
$primary: #00F5D4;
$accent: #F15BB5;
$support: #9B5DE5;
$theme: (
'surface': $surface,
'primary': $primary,
'accent': $accent,
'support': $support,
);
@function theme($key) {
@return map-get($theme, $key);
}
.badge { background: theme('accent'); }
// theme.ts β typed, so a typo is a build error
export const theme = {
surface: '#0F172A',
primary: '#00F5D4',
accent: '#F15BB5',
support: '#9B5DE5',
} as const;
export type ThemeColor = keyof typeof theme;
Β
5οΈβ£ tokens.json β the format that outlives your framework
If design tooling is anywhere near your project, export W3C-style design tokens. Style Dictionary, Figma plugins and Storybook all speak this.
Β
tokens/palette.tokens.json β the portable version
{
"$schema": "https://design-tokens.github.io/community-group/format/",
"palette": {
"$description": "Neon Diner Harmony",
"surface": { "$type": "color", "$value": "#0F172A" },
"primary": { "$type": "color", "$value": "#00F5D4" },
"accent": { "$type": "color", "$value": "#F15BB5" },
"support": { "$type": "color", "$value": "#9B5DE5" }
}
}
$ npx style-dictionary build
css
β build/css/variables.css
ios
β build/ios/StyleDictionaryColor.h
android
β build/android/colors.xml
Β
6οΈβ£ Light mode for free
Because components never touch hexes, an entire second theme is one media query. Swap the neon palette for a light one β here Quartz Drift β and keep every semantic name identical.
#F5F7F9 |
#E3DFF1 |
#E0AED7 |
#994129 |
|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
:root {
color-scheme: dark light;
}
@media (prefers-color-scheme: light) {
:root {
--color-surface: #F5F7F9;
--color-surface-alt: #E3DFF1;
--color-ink: #241B2F;
--color-ink-muted: #5B5468;
--color-primary: #994129; /* 6.22:1 on #F5F7F9 β
*/
--color-primary-ink: #FFFFFF;
--color-accent: #E0AED7;
--color-support: #7A5AA8;
}
}
/* user override beats the OS */
[data-theme='light'] { /* β¦same blockβ¦ */ }
// theme-toggle.js
const KEY = 'theme';
const saved = localStorage.getItem(KEY);
if (saved) document.documentElement.dataset.theme = saved;
document.querySelector('#toggle').addEventListener('click', () => {
const next = document.documentElement.dataset.theme === 'light' ? 'dark' : 'light';
document.documentElement.dataset.theme = next;
localStorage.setItem(KEY, next);
});
π§ Trap β do not reuse the same accent across both modes without re-checking it.
#00F5D4is 12.76:1 on the dark surface and 1.4:1 on white. Same color, opposite verdict. Each mode needs its own contrast pass.
Β
π€ Automate the boring 90%
Everything above is deterministic, so it can be a script. Feed it a palette, get every format out:
#!/usr/bin/env node
// palette-to-tokens.mjs β node palette-to-tokens.mjs 0F172A 00F5D4 F15BB5 9B5DE5
import { writeFileSync } from 'node:fs';
const NAMES = ['surface', 'primary', 'accent', 'support'];
const colors = process.argv.slice(2).map((c) => '#' + c.replace('#', '').toUpperCase());
const entries = NAMES.map((n, i) => [n, colors[i]]).filter(([, v]) => v);
const css = `:root {\n${entries.map(([n, v]) => ` --color-${n}: ${v};`).join('\n')}\n}\n`;
const tokens = Object.fromEntries(entries.map(([n, v]) => [n, { $type: 'color', $value: v }]));
const tw = `module.exports = { theme: { extend: { colors: ${JSON.stringify(
Object.fromEntries(entries), null, 2)} } } };\n`;
writeFileSync('theme.css', css);
writeFileSync('tokens.json', JSON.stringify({ palette: tokens }, null, 2));
writeFileSync('tailwind.colors.js', tw);
console.log('β theme.css β tokens.json β tailwind.colors.js');
$ node palette-to-tokens.mjs 0F172A 00F5D4 F15BB5 9B5DE5
β theme.css β tokens.json β tailwind.colors.js
Β
π§Ύ The checklist
- Write the raw palette into
palette.cssonce. Keep the source link in a comment. - Never let a component import layer one.
- Every brand color gets an
-inkpartner that is readable on top of it. - Export
tokens.jsonthe moment a designer is involved. - Re-run contrast per mode, not per color.
- Script the conversion β you will do it again next project.
Need a palette to run through the pipeline? ColorFiind lists 34,000+ of them with copy-ready HEX codes and a :root block on every page.
Β
Pick a palette, run the pipeline
Β
Next up: seasonal palettes as a theming system β why soft summer makes a beautiful reading app and deep winter makes the best dark mode you have shipped.








Top comments (0)