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 name rules
You type a bare name. The CMS adds the prefix and stores the full class.
| CMS field | Prefix the CMS adds | You type | It stores | What you write in tailwind/ |
|---|---|---|---|---|
| Theme | color-theme- | mybrand | color-theme-mybrand | @utility color-theme-mybrand { … } |
| Code Theme | code-theme- | mybrand | code-theme-mybrand | @utility code-theme-mybrand { … } |
| Font | font- | mybrand | font-mybrand | --font-mybrand in @theme, plus @font-face |
| Layout | — | not customizable | alpha | bravo |
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 send | Plan | Result |
|---|---|---|
| The field omitted entirely | any | Passes |
| The stored value echoed back unchanged | any | Passes |
| The field cleared | any | Passes |
| One built-in swapped for another | any | Passes |
| A new custom name, malformed (dashes, uppercase, over 32 chars) | any | 400 — “a custom name must be lowercase letters and numbers only…” |
| A new custom name, well-formed | below Starter | 402 upgrade_required, feature: "customThemeNames" |
| A new custom name, well-formed | Starter and up | Passes; 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.
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:
| Site | Shape | Why |
|---|---|---|
color-theme-leed | ~40 tokens, no dark block | Its brand tokens are --fg, --bg, --primary, --card, --border — names the documentation fallback chain already resolves through. The chain does the work. |
color-theme-pithy | Full clone with a dark block | Its 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)));--docs-*— your theme utility--site-*— your site palette, on:root, for the whole site--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-colorPlus 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:
| Token | Why it does nothing | Set this instead |
|---|---|---|
--docs-anchor-text-color | Heading anchors resolve from --docs-body-link-color | --docs-body-link-color |
--docs-anchor-text-color-hover | Same, from the hover twin | --docs-body-link-hover-color |
--docs-anchor-text-color-active | Same, from the active twin | --docs-body-link-active-color |
--docs-copied-content-bg-color | The Copied! flash ground is hard-wired to the accent | --docs-primary-color (and make the text legible on it) |
--docs-current-location-text-color | The active row’s text is hard-wired to the accent | --docs-primary-color |
--docs-header-bg-color-charlie | Never 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.
| Token | What the name suggests | What it actually does |
|---|---|---|
--docs-step-light-color | A light surface | It 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-light | A lighter accent, usable anywhere | It 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-color | The accent | It 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-color | A secondary button ground, light or dark to taste | The 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:
- Color theme
- Code theme
- Font
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.
Stored as code-theme-<name>. You write an @utility code-theme-<name> full of highlight.js class selectors with colors in the rules — no tokens, no contract. Imported unlayered. Degrades to no highlighting at all if the class is wrong.
Stored as font-<name>. You write a --font-<name> token in an @theme block plus an @font-face, and you put the .woff2 files under src/static/webfonts/. The @theme block is normally imported into layer(base) with the rest of your palette.
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 palette | Who 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:
- View source on a documentation page. Is
color-theme-mybrandon the<html>element? If not, the CMS value is wrong or the page type was not published. - 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. - 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.