Skip to content

Theming & Dark Mode

nib generates a complete light/dark theme system from day one — two separate semantic token files that feed both CSS custom properties and Pencil.dev variables. This guide explains how theming works at each layer.

Overview

semantic-light.tokens.json  ─┐
                              ├─→ build/css/variables.css     (CSS custom properties)
semantic-dark.tokens.json   ─┘    build/pencil/variables.json (Pencil themed arrays)

The source of truth is always the DTCG token files. nib brand build regenerates every platform output from them. Edit the source files, rebuild, and all targets stay in sync.


Token Files

After nib brand init, the tokens/color/ directory contains three files:

tokens/color/
├── primitives.tokens.json       ← raw 11-step color scales (never edit directly)
├── semantic-light.tokens.json   ← light mode semantic aliases
└── semantic-dark.tokens.json    ← dark mode semantic aliases

Semantic files reference primitive values using DTCG syntax:

json
// semantic-light.tokens.json
{
  "color": {
    "background": {
      "primary": { "$value": "{color.neutral.50}",  "$type": "color" }
    },
    "text": {
      "primary": { "$value": "{color.neutral.900}", "$type": "color" }
    }
  }
}
json
// semantic-dark.tokens.json
{
  "color": {
    "background": {
      "primary": { "$value": "{color.neutral.950}", "$type": "color" }
    },
    "text": {
      "primary": { "$value": "{color.neutral.50}",  "$type": "color" }
    }
  }
}

To customise dark mode colors, edit semantic-dark.tokens.json and run nib brand build. Do not edit primitives.tokens.json unless you need a different color scale — that file is regenerated by nib brand init.


CSS Output

nib brand build produces build/css/variables.css with three blocks:

1. Light mode (:root)

All semantic color tokens are written as CSS custom properties under :root — light mode is the default:

css
:root {
  /* Semantic Colors — Light */
  --color-background-primary: #f9fafb;
  --color-background-secondary: #f3f4f6;
  --color-text-primary: #111827;
  --color-text-secondary: #374151;
  --color-interactive-default: #2563eb;
  /* ... */

  /* Typography */
  --font-family-sans: Inter, system-ui, sans-serif;
  --font-size-body: 1rem;

  /* Spacing */
  --spacing-xs: 4px;
  --spacing-sm: 8px;
  --spacing-md: 16px;
  /* ... */
}

2. System dark mode (@media)

Dark semantic tokens override the light defaults when the OS is in dark mode:

css
@media (prefers-color-scheme: dark) {
  :root {
    --color-background-primary: #111827;
    --color-background-secondary: #1f2937;
    --color-text-primary: #f9fafb;
    --color-text-secondary: #e5e7eb;
    --color-interactive-default: #3b82f6;
    /* ... */
  }
}

3. Manual dark mode ([data-theme="dark"])

The same dark overrides are also written under a data attribute selector, enabling JavaScript-controlled theme switching without relying on the OS setting:

css
[data-theme="dark"] {
  --color-background-primary: #111827;
  --color-background-secondary: #1f2937;
  /* ... same as @media block ... */
}

Using Theme Switching

To let users toggle dark mode manually in your app:

js
// Toggle dark mode
document.documentElement.setAttribute('data-theme', 'dark');

// Return to light mode
document.documentElement.removeAttribute('data-theme');

Components use the CSS custom properties directly — no conditional logic needed in component code:

css
.card {
  background: var(--color-background-primary);
  color: var(--color-text-primary);
  border: 1px solid var(--color-border-primary);
}

Tailwind Integration

The Tailwind preset at build/tailwind/preset.js maps all tokens to Tailwind utility classes. Dark mode works through CSS variable resolution — the same var(--color-background-primary) value changes when the data attribute is set, so Tailwind classes like bg-background-primary respond automatically.

js
// tailwind.config.js
import nibPreset from './docs/design/system/build/tailwind/preset.js';

export default {
  presets: [nibPreset],
  darkMode: ['selector', '[data-theme="dark"]'],
};

See Framework Integration for the full Tailwind setup.


Pencil.dev Variable Theming

When you run nib brand push, nib reads both semantic token files and pushes color variables as themed arrays. Variables whose light and dark values differ are written in Pencil's themed format:

json
// build/pencil/variables.json (excerpt)
{
  "color-background-primary": {
    "type": "color",
    "value": [
      { "value": "#f9fafb", "theme": { "mode": "light" } },
      { "value": "#111827", "theme": { "mode": "dark"  } }
    ]
  },
  "color-text-primary": {
    "type": "color",
    "value": [
      { "value": "#111827", "theme": { "mode": "light" } },
      { "value": "#f9fafb", "theme": { "mode": "dark"  } }
    ]
  },
  "font-family-sans": {
    "type": "string",
    "value": "Inter"
  }
}

Variables whose light and dark values are identical (spacing, radius, typography sizes) are stored as plain scalars — no theme array needed:

json
{
  "spacing-md": { "type": "dimension", "value": "16px" },
  "border-radius-md": { "type": "dimension", "value": "8px" }
}

Pencil Theme Axis

When nib pushes themed variables, Pencil automatically registers a mode axis with two values: light and dark. light is the default — frames render in light mode unless you explicitly set a theme.

You can inspect this in Pencil's Variables panel (⌘ + 7 or View → Variables). The mode axis appears under the Themes section with light and dark as its values.

Applying a Theme to a Frame

To preview a frame in dark mode in Pencil:

  1. Select the frame
  2. Open the Design panel on the right
  3. Under Theme, set modedark

The frame renders with all --color-* variables resolved to their dark-mode values. Other frames remain in light mode. You can have both side by side in the same .pen file.

Explicit theme beats "last matching wins"

Pencil resolves themed variable values using a "last matching wins" rule — if multiple theme entries match a frame's context, the last one in the array wins. Always set the theme on frames explicitly (rather than relying on default fallthrough) to get predictable results.

Standard Variable Bridge

In addition to named semantic tokens, nib also pushes -- standard variables that Pencil style guides reference. These follow the same themed array format:

Pencil VariableMaps toThemed?
--backgroundcolor-background-primary✅ light/dark
--foregroundcolor-text-primary✅ light/dark
--primarycolor-interactive-default✅ light/dark
--bordercolor-border-primary✅ light/dark
--font-primaryfont-family-sans— (flat)
--space-mspacing-md— (flat)
--radius-mborder-radius-md— (flat)

See Brand System → Pencil Style Guide Bridge for the complete mapping table.


Folder Structure Reference

docs/design/system/                  ← configurable via --output
├── tokens/
│   └── color/
│       ├── primitives.tokens.json   ← raw scales — source
│       ├── semantic-light.tokens.json   ← light aliases — edit here
│       └── semantic-dark.tokens.json    ← dark aliases — edit here
└── build/
    ├── css/variables.css            ← :root + @media + [data-theme] blocks
    ├── tailwind/preset.js           ← Tailwind v3/v4 preset
    └── pencil/variables.json        ← flat + themed arrays for Pencil

Edit → Build cycle:

  1. Edit semantic-light.tokens.json or semantic-dark.tokens.json
  2. Run nib brand build → regenerates all three build outputs
  3. Run nib brand push → syncs updated variables into your .pen file

Customising Dark Mode

Change a specific dark-mode color

Open docs/design/system/tokens/color/semantic-dark.tokens.json and update the $value for the token you want to change:

json
{
  "color": {
    "background": {
      "primary": { "$value": "{color.neutral.900}", "$type": "color" }
    }
  }
}

You can reference any primitive: {color.brand.950}, {color.neutral.800}, etc. Run nib brand build to regenerate.

Add a custom dark-mode token

Add the token to both semantic files to keep them in sync:

json
// semantic-light.tokens.json
"color": { "surface": { "code": { "$value": "{color.neutral.100}", "$type": "color" } } }

// semantic-dark.tokens.json
"color": { "surface": { "code": { "$value": "{color.neutral.800}", "$type": "color" } } }

After rebuilding, the CSS output includes --color-surface-code in both the :root and dark mode blocks.

Disable dark mode

If your product is light-only, remove the @media and [data-theme] blocks from the generated CSS, or simply don't push with nib brand push (tokens still exist in files but won't be active in Pencil).


Roadmap: Multi-Brand Theming

Today nib supports one brand per project. A future release (GAP 15) will add a brand axis to the Pencil variable system, enabling a single .pen file to hold multiple brand variants (e.g. {mode: "light", brand: "acme"} vs {mode: "light", brand: "beta-corp"}). This unlocks white-label workflows and agency multi-client design without separate .pen files per client.

See the Multi-Brand Theming PRD in .dof/product/ for the full design.

Released under the AGPL-3.0 License.