Nakodo's styling starts from shadcn components, and the one instruction I had for myself was that it must not look like it. Not because shadcn's defaults are bad, but because five apps using the same slate-and-rounded-xl default is five apps nobody can tell apart.
So the component code stayed and the colour layer was replaced. The result is one 866-line globals.css with zero hex codes in it. Everything is OKLCH. You can see the output on the home page and in the deep blue panels on how it works.
Three hues, and a comment that says what each is for
/*
Nakodo colour system ("Cyanotype"). See docs/brand.md, section 5.3.
- cyano (h 252): the brand and the data. Buttons, links, focus, the fit score.
- paper (h 85) and ink (h 252): surfaces, text and borders.
- ochre (h 80): attention marks only (things waiting on you, keyword highlight, the
spot in illustrations). Never a button, a status or text.
Raw Tailwind palette classes and stray hex values don't belong in components.
*/
Three families, 27 steps between them:
@theme static {
--color-cyano-500: oklch(0.655 0.15 252);
--color-cyano-600: oklch(0.55 0.155 252);
--color-cyano-700: oklch(0.47 0.141 252);
/* … */
--color-paper-0: oklch(0.993 0.005 85);
--color-paper-150: oklch(0.932 0.013 85);
--color-ink-900: oklch(0.255 0.034 252);
}
Writing a ramp by hand in OKLCH is genuinely pleasant, which is the thing people underrate about it. The hue stays put, the chroma curve is yours to shape, and lightness is perceptual, so 0.47 is about as dark as 0.47 anywhere else. Ink is the same hue as the brand (252) with the chroma pulled down to between 0.014 and 0.034. Greys that share a hue with the accent read as part of the same object rather than as a different design.
Ochre is the only other hue, and it has a rule attached: attention marks only. Never a button, never a status, never body text. Three values exist, at 200, 400 and 900, and that is all the colour in the product that is not blue, paper, ink, or a status.
27 ramp steps, 87 semantic names
The ramp is never referenced by components. There is a second layer that gives every use a name:
@theme inline {
--color-primary: var(--primary);
--color-link: var(--link);
--color-panel: var(--panel);
--color-mark: var(--mark);
--color-score-3: var(--score-3);
--color-seq-4: var(--seq-4);
/* 87 of these */
}
Then :root binds those names to ramp steps:
:root {
--primary: var(--color-cyano-800);
--link: var(--color-cyano-700);
--panel: var(--color-cyano-800);
--mark: var(--color-ochre-400);
--score-3: var(--color-cyano-300);
}
Two layers sound like one too many until you try to change a decision. Darkening every primary button is one line in :root. Darkening the 800 step changes buttons, panels and the top of the score ramp at once. Having both means I can make either kind of change without discovering the other one by accident.
Five of those names are a score ramp with a matching foreground for each step, and six are a sequential ramp for charts. Both are the brand hue walking up its own lightness scale, so a chart and a button cannot disagree about what colour the product is.
The radii get flattened in the same block:
--radius-sm: 2px;
--radius-md: 4px;
--radius-lg: 6px;
--radius-xl: 6px;
--radius-2xl: 6px;
--radius-3xl: 6px;
--radius-4xl: 6px;
Every component that reaches for rounded-2xl or rounded-3xl gets 6px. That one block is most of the "it does not look like the default" effect, and it required editing no components at all.
Every value declared twice, deliberately
There is a full dark theme in that file, and right now nothing in the product turns it on. No toggle, no system-preference listener, nothing that adds the class. One component says so out loud rather than pretending:
// Always light: nothing turns on the .dark theme, so toasts don't follow the
// system setting either.
const Toaster = ({ ...props }: ToasterProps) => <Sonner theme="light" /* … */ />;
I wrote the dark set anyway, because writing it later means auditing 87 tokens in a product that has grown, and because getting it wrong taught me the thing this section is about. Its comment is one line and it cost me an afternoon to earn:
/* Every value is redeclared: a var() resolved on :root wouldn't pick up dark overrides. */
.dark {
--background: oklch(0.185 0.028 252);
--primary: var(--color-cyano-300);
/* 80 values, the whole set */
}
The tempting version of a dark theme overrides only what changes: background, foreground, border, a handful of others. It does not work once your tokens point at each other. --link: var(--color-cyano-700) resolves against the cyano ramp, and the ramp does not change in dark mode, so a link that is meant to get lighter stays at 700 and disappears into a dark background. Custom properties are substituted where they are declared, not re-resolved per theme.
So :root has 81 declarations, .dark has 80, and the only one missing is --radius, which has no business changing with the colour scheme. Every colour decision is made twice. It is more lines, and it is the only version that is actually correct.
Dark mode also flips direction rather than just dimming. In light mode --primary is cyano 800 with paper text on it; in dark it is cyano 300 with cyano 950 text on it. Same hue, opposite ends of the ramp, and the contrast is carried by lightness in both.
Panels invert by variable swap
One class does the thing I am most pleased with:
@layer components {
.panel {
background-color: var(--panel);
color: var(--panel-foreground);
--foreground: var(--panel-foreground);
--illo-line: var(--panel-foreground);
--illo-fill: var(--panel);
--hatch: color-mix(in oklch, var(--panel-foreground) 32%, transparent);
--muted-foreground: var(--panel-muted);
--border: color-mix(in oklch, var(--panel-line) 35%, transparent);
--link: var(--panel-foreground);
--ring: var(--color-cyano-300);
}
}
It is a surface, not an accent. Dropping .panel on a section redefines the tokens inside it, so text, borders, links, focus rings and the line art all invert without a single component being told it is on a dark background. No inverted prop threading down four levels.
Six utilities, and one of them has a forced-colors fallback
@utility label-caps { font-stretch: 75%; font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; /* … */ }
@utility catalogue { /* "No. 0147", "Guide 03" in mono */ }
@utility specimen-tag { clip-path: polygon(7px 0, 100% 0, 100% 100%, 7px 100%, 0 50%); padding-left: 14px; }
@utility hatch { background-image: repeating-linear-gradient(45deg, var(--hatch) 0 1px, transparent 1px 4px); }
@utility grain { /* an inline feTurbulence SVG, marketing panels only */ }
@utility rule-double { border-top: 3px double var(--rule); }
Hatching always means locked or not yet observed, and it always appears with text saying so, because a diagonal pattern is not information on its own. In forced-colors mode the pattern would be dropped by the browser and the meaning would go with it, so it becomes a border instead:
@media (forced-colors: active) {
.hatch {
background-image: none;
border: 1px dashed CanvasText;
}
}
CanvasText rather than a colour of ours, because the whole point of that mode is that the user picked the colours.
The grain is a feTurbulence SVG in a data URI at 5% alpha, on marketing panels only. No image request, no second file to keep in sync, and it does not run anywhere near the app's actual work.
The rule that keeps it honest
The last line of that header comment is the enforcement: raw Tailwind palette classes and stray hex values do not belong in components. It is a convention rather than a lint rule, but it is an easy one to hold, because once there are 87 named tokens there is nearly always one that already means what you are reaching for. The few times I have reached for bg-blue-500 instead, the reason was that I had not decided what the thing was yet, which is useful information in itself.
If you want to see the whole set rather than the write-up, every public page uses it: the glossary is the plainest one, mostly type, rules and marks, so the tokens are doing the work with nothing else to hide behind.
Top comments (0)