How to Implement a Dark Editorial Design System in Next.js

You can build a dark-only editorial design system in Next.js by configuring Tailwind CSS with CSS custom properties for colors, forcing the dark class on the HTML element, and wrapping the application in a ThemeProvider that disables system detection and locks the theme to dark mode.

The woosal1337/blog repository demonstrates a production-ready approach to creating a magazine-style reading experience on the web. This implementation combines Tailwind CSS configuration, forced theme states, and reusable design primitives to create a cohesive dark editorial design system in Next.js that prioritizes readability and visual hierarchy.

Tailwind CSS Configuration for Dark-Only Themes

The foundation of the system lives in tailwind.config.ts, where the configuration eschews standard Tailwind colors in favor of CSS custom properties that define a unified dark palette.

Enabling Class-Based Dark Mode

The configuration explicitly sets the dark mode strategy to class-based toggling:

// tailwind.config.ts
export default {
  darkMode: ["class"],
  // ...
}

This setting at line 5 allows the dark class on parent elements to trigger dark-specific utilities, but as you'll see below, the system forces this class permanently rather than toggling it.

CSS Custom Property Colors

All UI colors are expressed as rgb(var(--...)) values, creating a token-based system that applies consistently across the dark interface:

  • bg-[#0a0a0a] sets the near-black canvas
  • border-line and text-ink provide subtle borders and high-contrast typography
  • Background utilities reference custom properties like rgb(var(--background))

This approach in lines 101-150 of tailwind.config.ts ensures that every component references the same dark palette without hardcoding hex values throughout the codebase.

Editorial Typography and Spacing

The configuration defines a strict typographic scale that mirrors print editorial hierarchies:

// tailwind.config.ts
fontSize: {
  meta: ['0.75rem', { lineHeight: '1rem', letterSpacing: '0.05em', fontWeight: '500' }],
  caption: ['0.75rem', { lineHeight: '1rem' }],
  body: ['1rem', { lineHeight: '1.5rem' }],
  subhead: ['1.25rem', { lineHeight: '1.75rem' }],
  title: ['1.5rem', { lineHeight: '2rem', letterSpacing: '-0.01em', fontWeight: '600' }],
  headline: ['2rem', { lineHeight: '2.5rem', letterSpacing: '-0.02em', fontWeight: '600' }],
  display: ['3rem', { lineHeight: '3.5rem', letterSpacing: '-0.02em', fontWeight: '600' }],
}

Custom font families (ui, sans, mono) are injected for body text, headings, and code blocks (lines 34-45). The container system centers content with a max-width of 1120px, while a custom column width of 680px limits the reading column to optimal line lengths (lines 73-80).

Forcing the Dark Theme at the Layout Level

To guarantee the dark palette renders from the first paint, the root layout in app/layout.tsx implements a forced theme strategy.

HTML Class Injection

The <html> element receives a hardcoded dark class:

// app/layout.tsx (lines 86-90)
<html lang="en" className="dark ..." suppressHydrationWarning>
  <body className="bg-[#0a0a0a] text-ink">
    {children}
  </body>
</html>

This ensures that even before JavaScript executes, the browser applies dark-specific CSS custom properties.

Theme Provider Configuration

The ThemeProvider from next-themes is configured to reject system preferences and maintain a permanent dark state:

// app/layout.tsx (lines 108-113)
<ThemeProvider
  attribute="class"
  defaultTheme="dark"
  forcedTheme="dark"
  enableSystem={false}
>
  {children}
</ThemeProvider>

By setting forcedTheme="dark" and enableSystem={false}, the application prevents flash-of-light-mode during hydration and disables OS-level theme detection entirely.

Design System Primitives

The components/ds/ folder contains reusable UI blocks that implement the editorial visual language using the Tailwind configuration.

StoryCard Component

The StoryCard component in components/ds/story-card.tsx applies the dark palette with specific attention to contrast and borders:

// components/ds/story-card.tsx (lines 38-40)
<div className="bg-[#0a0a0a] border border-line rounded-lg overflow-hidden">
  {/* Card content */}
</div>

This ensures that even interactive elements maintain the dark aesthetic with subtle borders that define hierarchy without harsh contrasts.

Reveal Animation with Accessibility

The Reveal component in components/ds/reveal.tsx adds entrance animations while respecting user motion preferences:

// components/ds/reveal.tsx (lines 30-44)
const prefersReducedMotion = typeof window !== 'undefined' 
  ? window.matchMedia('(prefers-reduced-motion: reduce)').matches 
  : false;

if (prefersReducedMotion) {
  return <>{children}</>;
}

When motion is enabled, the component uses a custom easing curve defined in tailwind.config.ts as transitionTimingFunction.house (cubic-bezier(0.16, 1, 0.3, 1)), providing that characteristic editorial "snap" without feeling mechanical.

Practical Implementation Example

To use these primitives in a new page, import the design system components and wrap them in the container classes defined in your Tailwind configuration:

// pages/example.tsx
import { StoryCard } from '@/components/ds/story-card';
import { Reveal } from '@/components/ds/reveal';
import { ThemeSwitcher } from '@/components/ds/theme-switcher';

export default function ExamplePage() {
  return (
    <main className="container mx-auto py-12">
      {/* Theme toggle – UI element only, system stays dark */}
      <ThemeSwitcher className="mb-6" />

      {/* Animated reveal of the editorial card */}
      <Reveal delay={1}>
        <StoryCard
          href="/blog/first-post"
          title="Building a Dark Editorial UI"
          date="Aug 6 2026"
          label="Design"
          cover="/images/cover.jpg"
          isNew
          featured
        />
      </Reveal>
    </main>
  );
}

The container class respects the centered max-width of 1120px defined in tailwind.config.ts, while the ThemeSwitcher provides UI feedback (such as icon states) even though the underlying theme remains permanently dark due to the forcedTheme configuration.

Summary

  • Configure Tailwind with CSS custom properties in tailwind.config.ts to define a token-based dark palette using rgb(var(--...)) syntax for colors like bg-[#0a0a0a] and border-line.
  • Force the dark class on the <html> element in app/layout.tsx and configure ThemeProvider with forcedTheme="dark" and enableSystem={false} to prevent light-mode flashes.
  • Implement editorial typography scales (meta, caption, body, subhead, title, headline, display) and container constraints (max-width 1120px, reading column 680px) to mimic print magazine layouts.
  • Use design system primitives from components/ds/ like StoryCard and Reveal to maintain consistent dark styling, borders, and motion that respects prefers-reduced-motion.

Frequently Asked Questions

Why use CSS custom properties instead of static Tailwind colors?

CSS custom properties allow you to define semantic tokens like --background and --foreground that can be referenced throughout the design system. In woosal1337/blog, this approach centralizes the dark palette in tailwind.config.ts (lines 101-150) so that changing a single custom property updates the entire interface without searching for hardcoded hex values across dozens of component files.

How do you prevent flash of unstyled content in a forced dark theme?

The combination of adding className="dark" directly to the <html> element in app/layout.tsx (line 86) and configuring ThemeProvider with forcedTheme="dark" ensures the dark class is present before React hydration completes. The suppressHydrationWarning prop prevents console errors during the initial render when server and client HTML might differ slightly during theme initialization.

What is the optimal reading column width for editorial content?

According to the tailwind.config.ts configuration (lines 73-80), the repository uses a column width of 680px for the primary reading area. This falls within the ideal 60-75 character count per line for body text, balancing readability with the generous whitespace expected in editorial design. The outer container max-width of 1120px provides room for sidebars, metadata, or large imagery while maintaining a centered layout.

How does the system handle motion preferences for accessibility?

The Reveal component in components/ds/reveal.tsx checks for prefers-reduced-motion using window.matchMedia before applying any entrance animations. If the user has requested reduced motion, the component renders children immediately without the fade-in effect. When animation is enabled, it uses the custom house easing curve (cubic-bezier(0.16, 1, 0.3, 1)) defined in tailwind.config.ts for smooth, editorial-style motion that avoids jarring linear transitions.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →