Venture Crane

Building a Dark-Theme Design System with Tailwind v4

Feb 12, 2026 · 7 min read · by Venture Crane

Most design system guides start with light mode and bolt dark mode on as an afterthought. We went the other direction. Venture Crane’s site was designed dark-first - every color token, every contrast ratio, every surface elevation was conceived for a dark canvas. Here’s how we built it with Tailwind CSS v4 and vanilla CSS custom properties.

Why Dark-First

The conventional approach treats dark mode as an inversion. You design for white backgrounds, then flip to dark with prefers-color-scheme. This works, but it produces dark themes that feel like negatives of the light version rather than intentional designs.

We had a specific reason to go dark-first: Venture Crane is a development lab. Our audience is practitioners - developers and technical founders who spend most of their screen time in dark terminals and IDEs. A dark reading surface isn’t an accommodation; it’s the default expectation.

There’s a practical benefit too. When you design dark-first, you’re forced to think about elevation through surface tones rather than shadows. Shadows barely register against dark backgrounds. This constraint produced a cleaner hierarchy system than we’d have arrived at starting with light mode.

The Token Architecture

Three Layers of Color

Our system uses three semantic layers for background surfaces:

  1. Chrome (#1a1a2e) - structural elements like the header, footer, and homepage background
  2. Surface (#242438) - content reading areas where long-form text lives
  3. Surface Raised (#2a2a42) - cards, code blocks, and interactive elements that float above the surface

The chrome-to-surface transition is deliberate. When you navigate from the homepage to an article, the reading area shifts from #1a1a2e to #242438 - a subtle but noticeable increase in lightness that signals “you’re in reading mode now.” It’s only about 4 points different in HSL lightness, but your eyes register it immediately.

Custom Properties Over Theme Extensions

Tailwind v4 introduced a @theme directive that maps directly to CSS custom properties. We use a two-tier system:

:root {
  /* Source tokens - the actual values */
  --vc-chrome: #1a1a2e;
  --vc-surface: #242438;
  --vc-surface-raised: #2a2a42;
  --vc-text: #e8e8f0;
  --vc-text-muted: #a0a0b8;
  --vc-accent: #818cf8;
  --vc-accent-hover: #a5b4fc;
  --vc-gold: #dbb05c;
  --vc-gold-hover: #e8c474;
  --vc-border: #2e2e4a;
  --vc-code-bg: #14142a;
}

@theme {
  /* Tailwind mappings - reference the source tokens */
  --color-chrome: var(--vc-chrome);
  --color-surface: var(--vc-surface);
  --color-surface-raised: var(--vc-surface-raised);
  --color-accent: var(--vc-accent);
  --color-gold: var(--vc-gold);
  --color-border: var(--vc-border);
}

This looks like unnecessary indirection, but it serves a purpose. The :root tokens are plain CSS - any stylesheet, component <style> block, or third-party library can reference them. The @theme block maps these into Tailwind’s utility class system so bg-surface and text-accent work in class attributes. One set of values, two consumption patterns, zero duplication.

Contrast Ratios

Every color pairing was checked against WCAG AA (4.5:1) and AAA (7:1) thresholds. Here are the key ratios:

PairingForegroundBackgroundRatioGrade
Body text#e8e8f0#24243812.5:1AAA
Muted text#a0a0b8#2424385.9:1AA
Accent links#818cf8#2424385.1:1AA
Gold wordmark#dbb05c#1a1a2e8.4:1AAA
Code text#e8e8f0#14142a14.8:1AAA
Muted on chrome#a0a0b8#1a1a2e6.7:1AA

The gold accent (#dbb05c) was chosen for its warmth and AAA-clearing contrast on the chrome background at 8.4:1.

Typography Decisions

The Body Text Baseline

We set body text to 1rem (16px) with a 1.6 line height. This matches the industry-standard base size but pairs it with a more generous line height than the browser default of 1.5, giving dark-mode reading room to breathe.

The same optical illusion that makes white text on black appear bolder than black text on white also makes tightly-spaced text harder to parse on dark backgrounds. At 16px with a 1.6 line height, sustained reading is comfortable without feeling oversized.

:root {
  --vc-text-body: 1rem;
  --vc-leading-body: 1.6;
}

System Font Stacks

We avoided loading custom fonts entirely. The font stack falls through platform natives:

  • Body: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif
  • Mono: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace

Zero font files means zero layout shift from font loading, zero FOUT, and one fewer thing to cache-bust. Every operating system ships a good sans-serif and a good monospace. Use them.

The Type Scale

We defined a five-step scale covering everything from page titles to metadata:

ElementSizeLine HeightWeight
H12rem (32px)1.2700
H21.5rem (24px)1.3600
H31.25rem (20px)1.4600
Body1rem (16px)1.6400
Small/Meta0.875rem (14px)1.5400

Each step is roughly a 1.25x ratio - not a mathematically perfect scale, but one tuned for readability at each individual level. Strict modular scales often produce awkward sizes at the extremes. We preferred each level looking right on its own.

Component Patterns

Prose Container

All rendered markdown lives inside .vc-prose, which applies spacing, list styles, and link colors. This is a deliberate alternative to Tailwind’s official @tailwindcss/typography plugin.

We avoided the typography plugin because its reset approach conflicted with our token system. When you’ve already defined --vc-text and --vc-accent as CSS variables, layering on a plugin that generates its own color values creates a maintenance surface. One source of truth is better than two.

The .vc-prose class handles:

  • Heading margins (margin-top: 2.5em for h2, 1.5em for h3)
  • Paragraph spacing (margin-bottom: 1.25em)
  • List indentation (padding-left: 1.5em)
  • Blockquote styling (accent-color left border, muted italic text)
  • Table formatting (collapsed borders, raised-surface header background)
  • Inline code (raised-surface background with slight padding)

Code Block Overflow

Code blocks need special treatment in constrained layouts. A 768px content column (roughly 660px of prose after card padding) can’t fit a 120-character line without overflow. We handle this with two layers:

  1. overflow-x: auto on the <pre> element
  2. tabindex="0" added via a rehype plugin for keyboard scrollability

The first handles the visual overflow. The second is an accessibility detail that’s easy to miss - without tabindex="0", keyboard users can’t scroll horizontally through long code blocks. Our rehype plugin adds it automatically during the Astro build.

A technique we’ve been evaluating but haven’t shipped yet: a CSS ::after pseudo-element that creates a right-edge fade gradient hinting at overflow. The idea is a sticky pseudo-element that fades from transparent to the code background color:

pre::after {
  content: '';
  position: sticky;
  right: 0;
  display: block;
  width: 2rem;
  height: 100%;
  margin-top: -100%;
  background: linear-gradient(to right, transparent, var(--vc-code-bg));
  pointer-events: none;
}

No JavaScript, no intersection observers - just CSS. We haven’t added it because the current overflow-x: auto approach works well enough, and the fade gradient introduces visual complexity on every code block regardless of whether it actually overflows. Sometimes the simpler solution is the right one.

Table Scroll Shadows

Tables face the same overflow problem as code blocks, but we solve it differently. A rehype plugin wraps each <table> in a <div class="table-wrapper"> with role="region" and tabindex="0" for accessibility. The wrapper handles scrolling.

The clever part is the scroll shadow technique using background-attachment: local versus scroll. Four gradient backgrounds create shadow indicators that appear only when content is scrollable in that direction:

.table-wrapper {
  overflow-x: auto;
  background:
    linear-gradient(to right, var(--vc-surface), var(--vc-surface)) local,
    linear-gradient(to left, var(--vc-surface), var(--vc-surface)) local,
    linear-gradient(to right, rgba(0, 0, 0, 0.25), transparent) scroll,
    linear-gradient(to left, rgba(0, 0, 0, 0.25), transparent) scroll;
  background-size:
    2rem 100%,
    2rem 100%,
    1rem 100%,
    1rem 100%;
  background-position: left, right, left, right;
  background-repeat: no-repeat;
}

The local backgrounds scroll with the content; the scroll backgrounds stay fixed. When you scroll right, the left local gradient moves away, revealing the left scroll shadow. It’s CSS-only, performant, and degrades gracefully - if a browser doesn’t support background-attachment: local, you just don’t get shadow indicators.

Lessons Learned

Token Naming Matters More Than Values

We initially named our colors --bg-dark, --bg-medium, --bg-light. This fell apart immediately when discussing designs: “Use the medium background” told you nothing about intent. Renaming to chrome, surface, and surface-raised made every conversation clearer. The name describes the role, not the lightness.

Test at the Extremes

Our reading comfort check isn’t “does this look okay for 30 seconds.” It’s “can I read this for 5+ minutes without wanting to adjust brightness.” Dark themes fail in sustained reading far more often than in quick glances. The combination of 16px text, 1.6 line height, and the #242438 surface (slightly lighter than the chrome) was the result of iterating through several background candidates.

Don’t Build What Astro Gives You

We started writing a custom Markdown processing pipeline before realizing Astro’s built-in content collections already handled 90% of what we needed. The only custom piece is a single rehype plugin that adds tabindex attributes and wraps tables. Everything else - frontmatter parsing, type-safe schemas, slug generation, RSS feeds - is Astro out of the box.

Share: Hacker News Reddit

Related