Custom Documentation Themes

The CMS lets you type your own name for a documentation color theme, code theme or font instead of picking one of the built-ins. That name is stored, stamped onto the <html> element of every documentation page, and otherwise means nothing whatsoever. It is a CSS class, and this page is where you make the class exist.

Two halves, one seam. The CMS owns the name. Your site repository owns the CSS the name refers to. Neither half is any use without the other, and the failure when you have only the first is silent: a class with no rule behind it is not an error, it is just a page that renders in Leed’s default blue.

The Theme field in CMS Settings with the Custom option selected and a name typed into the free-text input

The name rules

You type a bare name. The CMS adds the prefix and stores the full class.

CMS fieldPrefix the CMS addsYou typeIt storesWhat you write in tailwind/
Themecolor-theme-mybrandcolor-theme-mybrand@utility color-theme-mybrand { … }
Code Themecode-theme-mybrandcode-theme-mybrand@utility code-theme-mybrand { … }
Fontfont-mybrandfont-mybrand--font-mybrand in @theme, plus @font-face
Layout—not customizablealphabravo

The bare name is validated against ^[a-z0-9]{1,32}$: lowercase letters and digits only, no dashes, at most 32 characters.

Layout is excluded by design, at any price. layoutName is not a class at all — the site builder turns it into a Handlebars partial path, leed/docs/<layoutName>/base, so a value with no matching partial is a build failure rather than an unstyled page. There are exactly three layouts and no mechanism for a fourth. The three are cataloged on Themes, Fonts and Code Themes.

What the gate does and does not block

The gate matters most to a customer who had Starter and no longer does, so its semantics are deliberately narrow. It fires only on a change that introduces a non-built-in value, and it shape-checks every introduced field, not just the first — a well-formed color theme never vouches for a malformed font name in the same request.

What you sendPlanResult
The field omitted entirelyanyPasses
The stored value echoed back unchangedanyPasses
The field clearedanyPasses
One built-in swapped for anotheranyPasses
A new custom name, malformed (dashes, uppercase, over 32 chars)any400 — “a custom name must be lowercase letters and numbers only…”
A new custom name, well-formedbelow Starter402 upgrade_required, feature: "customThemeNames"
A new custom name, well-formedStarter and upPasses; the full class is stored

The reason for the change-only design is worth knowing, because it explains behavior that would otherwise look like a bug: the Settings screen re-sends the whole documentation configuration when it mounts, and the page-type editor always sends the complete object. An unconditional gate would return a 402 to a downgraded company merely for opening Settings. It does not; the stored custom theme keeps working and keeps rendering, and the only thing they cannot do is introduce a new one.

The same Theme field on a Free-plan workspace, showing the upgrade prompt in place of the custom-name input

Writing a custom color theme

Here is a complete, working theme. Put it in tailwind/docs/color-theme-mybrand.css and import it from site.config.css.

@utility color-theme-mybrand {
  /* accent */
  --docs-primary-color: var(--primary);
  --docs-primary-color-hover: var(--primary-hover);
  --docs-primary-light: var(--primary);

  /* ground and type */
  --docs-bg-color: var(--bg);
  --docs-text-color: var(--fg);
  --docs-border-color: var(--border);

  /* links */
  --docs-body-link-color: var(--primary);
  --docs-body-link-hover-color: var(--primary-hover);
  --docs-body-link-active-color: var(--primary);

  /* headings */
  --docs-header-1-color: var(--fg);
  --docs-header-2-color: var(--fg);
  --docs-header-3-color: var(--fg);
  --docs-header-4-color: var(--fg);
  --docs-header-5-color: var(--fg);
  --docs-header-6-color: var(--fg);

  /* code */
  --docs-code-bg-color: var(--bg-secondary);
  --docs-code-inner-bg-color: var(--card);
  --docs-code-border-color: var(--border);

  /* chrome */
  --docs-header-bg-color: var(--card);
  --docs-header-border-color: var(--border);
  --docs-menu-bg-color: var(--card);

  @apply docs-theme-defaults;
}

Two parts of that are not obvious.

It must be an @utility, not a class. The name reaches the page as a utility class name on <html>, and Tailwind only emits a custom utility it finds referenced in scanned source. A plain .color-theme-mybrand { … } rule would be emitted unconditionally and would still apply — but it would land in the wrong place in the cascade, and it would not be tree-shaken, which is the mechanism the whole theme system relies on. Write @utility.

@apply docs-theme-defaults; is required and goes last. That utility is what wires body ground and text, h1–h6, the body-link colors inside the article, and the .nav-logo / .logo-footer background image that reads --nav-logo-url. Every one of Leed’s seventeen shipped themes ends with it. A theme without it sets a pile of tokens that nothing has been told to read.

Map onto your own tokens — and skip the dark block entirely

Start here, because it halves the file and removes a whole class of bug.

Leed’s shipped themes each repeat their entire token list a second time inside a prefers-color-scheme block. They have to: they are written in literal Tailwind ramp references and know nothing about your site. Yours is not in that position. If your site palette already swaps under one media query — and it should; the shape is on Dark Mode — then a theme written as --docs-text-color: var(--fg) needs no dark block at all. There is one definition of the color, in one file, and light and dark cannot drift apart because there is nothing to keep in step.

Both worked examples on this system are real, shipped and buildable, and they differ on exactly this point:

SiteShapeWhy
color-theme-leed~40 tokens, no dark blockIts brand tokens are --fg, --bg, --primary, --card, --border — names the documentation fallback chain already resolves through. The chain does the work.
color-theme-pithyFull clone with a dark blockIts brand tokens live in a --pithy-* namespace the documentation shell has never heard of, so every value has to be restated per scheme.

Read the first one if you can. A clone is only necessary when your palette is namespaced somewhere the chain cannot reach.

A theme is a delta, not a clone

Leed’s documentation components never read a --docs-* token directly. They read --component-*, defined once as a three-deep fallback:

--component-primary-color: var(--docs-primary-color,
                             var(--site-primary-color,
                                 var(--default-primary-color)));
  1. --docs-* — your theme utility
  2. --site-* — your site palette, on :root, for the whole site
  3. --default-* — Leed’s own blue

An omitted --docs-* token therefore falls through to your --site-* value and picks up your site’s palette for free. When the documentation lives on the same site as the marketing pages, you only declare what genuinely differs.

leed.ai’s theme is a good example of what “genuinely differs” means in practice. It sets --docs-text-color: var(--fg) — full ink — where the site’s own --site-text-color is a muted neutral-600. That is not a mistake in the site palette; muted body copy reads well in a marketing column and is too light to read a 2,000-word reference page in. The theme states the divergence and inherits everything else.

The full chain, and the forty --site-* names that form its middle rung, are on the Site Token Contract.

Which --docs-* tokens to set

A shipped theme like color-theme-blue declares 40 custom properties: 32 --docs-* names plus eight bare-prefix ones (--form-*, --menu-footer-header-text-color, --api-*, the reading-progress trio). Forty-two distinct --docs-* names have a consumer today.

The full list — every --docs-* name with a live consumer
accent
  --docs-primary-color                    the accent, and four other things (see below)
  --docs-primary-color-hover
  --docs-primary-light                    must be SOLID (see below)

ground and type
  --docs-bg-color                         also the ground behind a zoomed diagram
  --docs-text-color
  --docs-border-color
  --docs-fixed-body-bg-color              bravo layout only

headings
  --docs-header-1-color … --docs-header-6-color

links — these also feed the ¶ heading anchors
  --docs-body-link-color
  --docs-body-link-hover-color
  --docs-body-link-active-color

chrome
  --docs-header-bg-color
  --docs-header-border-color
  --docs-menu-bg-color

code
  --docs-code-bg-color                    the outer frame
  --docs-code-inner-bg-color              the inner well
  --docs-code-border-color
  --docs-code-bg-color-selection
  --docs-code-math-text-color
  --docs-copied-content-text-color        must be legible on the accent

navigation, search, cards
  --docs-current-location-bg-color
  --docs-sidebar-link-bg-color            resting row; defaults to transparent
  --docs-sidebar-link-hover-bg-color
  --docs-sidebar-link-current-bg-color    falls through to the hover token
  --docs-search-button-bg-color
  --docs-search-modal-bg-color
  --docs-search-result-bg-color
  --docs-card-bg-color                    API reference cards
  --docs-step-light-color                 a BORDER, despite the name

forms
  --docs-form-border-color
  --docs-form-border-color-hover

buttons, pagination, footer
  --docs-button-secondary-bg-color        must be dark in BOTH schemes
  --docs-button-secondary-bg-hover-color
  --docs-pagination-icon-color
  --docs-pagination-icon-color-hover
  --docs-footer-social-icons-color
  --docs-footer-social-icons-hover-color

Plus the bare-prefix names, which are read directly with no --docs-* rung in front of them and which a shipped theme therefore sets inside its own utility: --form-border-color, --form-border-color-hover, --form-download-spinner-text, --menu-footer-header-text-color, --api-snippets-bg-color, --api-select-list-item-bg-color-hover, and the three --scroll-status-indicator-horizontal-* names for the reading-progress bar. Note that the progress-bar trio is the one place where the layered-versus-unlayered question below decides whether your theme takes effect at all.

Six names look like tokens, are set by other people’s themes, and are read by nothing on either the shipped builder or its development branch. Setting one and seeing no change is expected behavior, and there is currently no way to discover that from the product, so here is the list:

TokenWhy it does nothingSet this instead
--docs-anchor-text-colorHeading anchors resolve from --docs-body-link-color--docs-body-link-color
--docs-anchor-text-color-hoverSame, from the hover twin--docs-body-link-hover-color
--docs-anchor-text-color-activeSame, from the active twin--docs-body-link-active-color
--docs-copied-content-bg-colorThe Copied! flash ground is hard-wired to the accent--docs-primary-color (and make the text legible on it)
--docs-current-location-text-colorThe active row’s text is hard-wired to the accent--docs-primary-color
--docs-header-bg-color-charlieNever had a consumer; the shipped themes no longer set it--docs-header-bg-color

There is no way to theme the heading anchors separately from body links. That is a consequence of the chain, not an oversight you can work around with a token.

Four tokens whose name does not describe their use

Each of these has cost somebody an afternoon.

TokenWhat the name suggestsWhat it actually does
--docs-step-light-colorA light surfaceIt is only ever used as a border — the border-b divider between stacked rows, in all three layouts. A surface value here produces a divider that is invisible in one scheme.
--docs-primary-lightA lighter accent, usable anywhereIt is read as a 7% wash behind navigation rows. It must therefore be a solid color: a token that is already 12%-transparent in dark mode gives you 7% of 12%, which is nothing.
--docs-primary-colorThe accentIt is also hard-wired as the active navigation text, the active TOC rule, the active tab underline and the Copied! flash ground. Changing it moves five things.
--docs-button-secondary-bg-colorA secondary button ground, light or dark to tasteThe secondary button’s text is a forced text-white! and is not themeable, so this ground must be dark enough for white type in both schemes. A semantic surface token would be white-on-white in light mode.

The charlie layout and the active sidebar row

Under the charlie layout, the sidebar’s aria-current row and the menu hover state are painted bg-transparent by the layout’s own stylesheet, at higher specificity than the token defaults. So on charlie, setting --docs-current-location-bg-color or --docs-sidebar-link-hover-bg-color and seeing nothing in the sidebar is expected: charlie carries the active state as accent-colored text, not as a ground, and the tokens still apply to the mobile bar.

That override no longer carries !important — the builder dropped the forcing precisely so a site could take these rows back — so you can out-specify it from your own stylesheet if you want a painted row. The layer mechanics for doing that without !important are on Cascade Layers and Overriding Leed.

Writing a custom code theme

Correct the expectation you probably arrived with: there is no --code-* prefix and no token contract. Extracting custom properties from all fourteen shipped code themes returns zero. A code theme is an @utility containing highlight.js class selectors with colors written into the rules, and that is the entire API.

@utility code-theme-mybrand {
  /* inline <code> that highlight.js has touched */
  code.hljs {
    @apply px-[5px] py-[3px];
  }

  /* base */
  .hljs {
    @apply text-(--fg);
  }

  /* keywords and control flow */
  .hljs-keyword,
  .hljs-doctag,
  .hljs-template-tag,
  .hljs-template-variable,
  .hljs-type,
  .hljs-variable.language_ {
    @apply text-(--primary) font-medium;
  }

  /* names */
  .hljs-title,
  .hljs-title.class_,
  .hljs-title.function_,
  .hljs-section {
    @apply text-(--fg) font-semibold;
  }

  /* strings, numbers, literals */
  .hljs-string,
  .hljs-regexp,
  .hljs-number,
  .hljs-literal,
  .hljs-built_in {
    @apply text-(--fg-muted);
  }

  /* comments, meta, tags */
  .hljs-comment,
  .hljs-quote {
    @apply text-(--fg-subtle) italic;
  }
  .hljs-meta,
  .hljs-tag {
    @apply text-(--fg-subtle);
  }

  /* diffs */
  .hljs-addition { @apply bg-(--success-soft) text-(--fg); }
  .hljs-deletion { @apply bg-(--error-soft) text-(--fg); }

  /* emphasis */
  .hljs-emphasis { @apply italic; }
  .hljs-strong   { @apply font-bold; }
}

The selector groups worth covering, in the order you will meet them: container (pre code.hljs), base (.hljs), comments and quotes, keywords and doctags, titles and names, strings and regexps, numbers, literals, built-ins and types, identifiers, attributes and variables, meta and tags, links, .hljs-addition and .hljs-deletion, and emphasis and strong.

The shipped themes write every color twice — once plain, once behind Tailwind’s dark: variant, roughly forty pairs per theme. Writing the rules as text-(--fg) against tokens that already swap gives you both schemes from one definition, exactly as it does for a color theme. The -soft status tokens the diff rules use above are the same pair that Theming Alerts asks you to add to your palette — three consumers want them, so define them once at site level.

The fourteen built-ins are good starting points if you want conventional multi-hue highlighting; copy one and re-point the colors.

The box is Leed’s; the colors are yours

Leed’s own component stylesheet already owns a code block’s geometry and both of its surfaces — the outer pre carries the radius, border, overflow and copy-button clearance plus --docs-code-bg-color, and the inner code carries its padding plus --docs-code-inner-bg-color. Your code theme sets no background, no padding, no radius and no border. Set the two grounds from your color theme instead.

Color themes degrade gracefully; code themes do not

The two failures look nothing alike, and they need different fixes.

A missing or misspelled colorTheme falls through the token chain to your --site-* palette and then to Leed’s defaults. The page still looks like a page. It looks un-branded, which is a mild and easily-missed symptom.

A missing or misspelled codeTheme leaves code blocks completely unstyled — no syntax color at all, because there is no token chain behind a code theme to fall through to. That one you will notice immediately, and the fix is almost always the class name rather than the CSS.

Writing a custom font name

A font name is the third field the CMS will accept a custom value for, and it needs two things in your repository: a --font-mybrand token in an @theme block so the utility exists, and an @font-face so the face loads.

@theme {
  --font-mybrand: "My Brand", ui-sans-serif, system-ui, sans-serif;
}

The file rules — woff2 only, 500 KB per file, where the files go and how the URL is rewritten — are on Fonts and Webfonts, along with the one token that governs every monospace surface in the documentation CSS, --font-mono. Set that one rather than putting a brand mono on your .hljs rules, or inline code and the API reference will stay on the system stack.

Which field you are configuring changes what you write, and only that:

Stored as color-theme-<name>. You write an @utility color-theme-<name> full of --docs-* tokens, ending in @apply docs-theme-defaults;. Imported unlayered. Degrades to the site palette if the class is wrong.

Where the file goes, and why it is unlayered

Import your theme from site.config.css without a layer(...) annotation:

@import "./docs/color-theme-mybrand.css";
@import "./docs/code-theme-mybrand.css";

The reason is mechanical: @utility cannot be nested inside a cascade layer, so the annotation is not available to you. Tailwind compiles the utility into its own utilities layer, which is the last of @layer theme, base, components, utilities.

The consequence is not obvious from that sentence, and it silently changes whether a token takes effect. Whether your theme’s tokens beat your site’s own :root block depends entirely on how your site imports that block:

How your site imports its paletteWho wins on a documentation page
@import "./site/theme.css" layer(base);The theme utility wins. A token restated in the theme genuinely overrides the site’s, for documentation pages only.
@import "./site/theme.css"; (unlayered)The site’s :root wins. Unlayered normal declarations beat every layer, so a token restated in the theme is silently ineffective.

The two reference implementations differ on exactly this. leed.ai layers its palette into base, so its theme deliberately re-points three tokens — a bare --form-border-color, a spinner color and a footer heading color — for documentation pages only, and gets a real override. Pithy imports its palette unlayered, so the same restatement there does nothing at all and its file carries a note saying so.

Neither arrangement is wrong. What is wrong is not knowing which one you are in, and concluding that a token is dead when it is merely being outranked.

How the class reaches the page — and the scanner dependency behind it

colorTheme, codeTheme and font store the full class string. Only layoutName is stored bare and gets its docs-layout- prefix at render time. The leedConfiguredCSS shortcode joins all four and the documentation shell stamps the result onto the root element:

<html lang="en" class="scroll-smooth js-focus-visible leed-documentation
      font-noto-sans docs-layout-charlie color-theme-leed code-theme-leed">

Then the part nobody expects. Tailwind emits a custom utility only if it finds that class name in scanned source. Your entry file begins @import "tailwindcss" source("../src"), so the scan root is the content repository’s src/ — and JSON data files under it are scanned as text like anything else. The theme name reaches the scanner through the documentationConfiguration block in a .11tydata.json file under src/, which is where the class literal lives.

Measured on Pithy’s real 432 KB build output: .color-theme-pithy and .code-theme-pithy are emitted, while color-theme-blue and code-theme-github have zero occurrences. Tree-shaking is real, and the other sixteen color themes and thirteen code themes did not ship.

One mechanical requirement follows from the same machinery: site.config.css must contain a line matching @import "tailwindcss" verbatim, because the builder regex-matches it to inject Leed’s own stylesheet immediately after. That is covered on Tailwind Build.

Applying it

Set the name in Settings → General (the company default) or on the page type in Settings → Page Types, then publish the page type and rebuild.

Then verify, in this order, because each step rules out the one below it:

  1. View source on a documentation page. Is color-theme-mybrand on the <html> element? If not, the CMS value is wrong or the page type was not published.
  2. Search the built stylesheet for .color-theme-mybrand. If the class is on <html> but not in the CSS, the utility was tree-shaken — the literal is not reaching the scanner.
  3. Inspect an element and read a computed token. If both of the above are fine and the color is still wrong, you are in a cascade fight — check whether your site palette is layered or unlayered, per the table above.
ESC