Designing a Token-Based Design System for Dark Interfaces
How I built the semantic color tokens, typography scale, and spacing system that powers this portfolio.
This portfolio runs on a design system I built from scratch: a set of tokens that define every color, font size, and spacing value used across the site. Not because I needed a "design system" for a personal project, but because I was tired of making ad-hoc color decisions in every component file.
Why Tokens Instead of Raw Values
The first version of this site had colors like bg-gray-900, text-gray-400, and border-gray-800 scattered across thirty components. It worked until I tried to adjust the overall feel, making the background slightly warmer, the text slightly brighter. Every change meant updating dozens of files and hoping I didn't miss one.
Tokens solve this by adding a semantic layer between the raw values and the components:
export const semantic = {
bg: {
primary: primitives.colors.gray[950],
secondary: primitives.colors.gray[900],
elevated: primitives.colors.gray[800],
},
text: {
primary: primitives.colors.gray[50],
secondary: primitives.colors.gray[400],
tertiary: primitives.colors.gray[500],
muted: primitives.colors.gray[600],
},
border: {
default: primitives.colors.gray[800],
subtle: primitives.colors.gray[900],
},
surface: {
card: primitives.colors.gray[900],
hover: primitives.colors.gray[800],
},
}
Now components use bg-bg-primary, text-text-secondary, border-border-default. If I want to adjust the hover state of every card on the site, I change one value. The semantic names mean I never have to remember whether "the card background" is gray-900 or gray-800.
The Color Architecture
Dark interfaces need more thought than "invert a light theme." The hierarchy works differently in dark mode because lighter values draw the eye more aggressively.
My approach uses a single gray scale with intentional jumps:
- 950 → Background. The darkest value, nearly black.
- 900 → Cards and secondary surfaces. Just enough contrast to distinguish from background.
- 800 → Elevated surfaces, hover states, default borders.
- 700 → Strong borders when needed.
- 600 → Muted text (labels, timestamps, secondary info).
- 500 → Tertiary text (hints, placeholders).
- 400 → Secondary text (body copy, descriptions).
- 50 → Primary text (headings, important content).
The gap between 400 and 50 is intentional. There's no gradual ramp for primary text. It snaps to near-white because headings on dark backgrounds need strong contrast to feel solid.
Typography Scale
The font stack is Geist Sans for body and Geist Mono for code and labels. The scale uses rem with explicit line heights for each size:
fontSize: {
xs: ['0.75rem', { lineHeight: '1rem' }],
sm: ['0.875rem', { lineHeight: '1.25rem' }],
base: ['1rem', { lineHeight: '1.6' }],
lg: ['1.125rem', { lineHeight: '1.6' }],
xl: ['1.25rem', { lineHeight: '1.75rem' }],
'2xl': ['1.5rem', { lineHeight: '1.3' }],
'3xl': ['1.875rem', { lineHeight: '1.2' }],
}
Body text (base and lg) gets a line height of 1.6 for comfortable reading. Headings get progressively tighter line heights as they scale up because large text with generous leading looks like it's floating apart.
The mono utility I use everywhere:
.label-mono {
@apply font-mono text-xs uppercase tracking-[0.2em] text-text-tertiary;
}
This appears on section labels, metadata, and navigation. The wide tracking and uppercase treatment creates a visual signature that runs through the entire site, a quiet but consistent element that ties different pages together.
Spacing and Rhythm
Spacing uses a 4px base grid: 4, 8, 12, 16, 20, 24, 32, 48, 64, 96. Not every multiple of 4 is in the scale. I removed values I never used to force consistency.
The key insight is that vertical rhythm matters more than horizontal alignment on dark interfaces. Because the background is uniform, the eye uses vertical spacing to understand hierarchy. I use larger gaps between sections (96px, 128px) and tighter gaps within sections (16px, 24px) to create clear groupings.
Motion Tokens
Timing and easing are tokens too:
timing: {
fast: '150ms',
normal: '250ms',
slow: '400ms',
page: '600ms',
},
easing: {
out: 'cubic-bezier(0.22, 1, 0.36, 1)',
inOut: 'cubic-bezier(0.65, 0, 0.35, 1)',
},
fast for hover states and micro-interactions. normal for element transitions (dropdowns, tooltips). slow for entrance animations. page for route transitions.
The out easing (fast start, slow finish) is the default for almost everything. It feels responsive because the animation starts immediately and decelerates naturally.
What I'd Change
The system works for a portfolio, but it's not production-grade for a multi-person team. If I were building this for a real product:
- Add component tokens. Button sizes, input heights, card padding as named values
- Document decisions. Why each value was chosen, not just what it is
- Add a theme switcher. The token architecture supports light mode, but I haven't built it because this site doesn't need it
- Version the tokens. So breaking changes to the scale are explicit
For a personal site, the current level is right. It gives me consistency without bureaucracy.