Theming and Customization

This content was generated with the assistance of AI and should be verified against the official documentation of each tool before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page was written against Zensical 0.0.72 (released 2026-10-23, still marked alpha on PyPI) and checked on 2026-10-26. It covers the customization layers of a Zensical site, from the cheapest (a few lines in zensical.toml) to the most invasive (template overrides). Every listing that builds a site was run against a copy of the Pocket Units documentation project with Zensical 0.0.72 and Python 3.13.

Customization layers

Zensical keeps the visual design of Material for MkDocs, so the settings and the CSS variables keep their md- names. The layers below are ordered from least to most effort. Prefer the earliest layer that does the job, because every override you own is a file that must be compared with the built-in one after an upgrade.

Layer Where Typical use

Theme settings

[project.theme] in zensical.toml (theme: in mkdocs.yml)

Palette, fonts, feature flags, language, logo

Extra assets

extra_css, extra_javascript

Custom colors, a self-hosted font, small scripts

Block overrides

custom_dir with a main.html that extends base.html

Announcement bar, extra <head> tags, a different content block

Partial overrides

custom_dir with a file of the same relative path, such as partials/copyright.html

Replace the footer copyright, the header or the table of contents

Custom pages and error pages

custom_dir with new templates and 404.html

Landing pages chosen with the template front matter key, a branded not-found page

Template lookup: custom_dir first, then the built-in theme, then MiniJinja renders HTML

Figure: a template named in a build is looked up in custom_dir first and in the built-in theme second, and both are rendered by MiniJinja without a Python interpreter.

The classic variant keeps the Material for MkDocs look, and modern is the other variant (theme.variant, see Migrating from Material for MkDocs). Where this page says that a setting colors "the header", the official colors page states that primary color applies to the header and sidebar in the classic variant.

Color palette and schemes

The palette lives in [project.theme.palette]. It has three independent pieces: the scheme, the primary color and the accent color.

  • scheme: default is the light scheme and slate is the dark scheme.

  • primary: used for text links and, in the classic variant, for the header and the sidebar. Allowed names are red, pink, purple, deep purple, indigo, blue, light blue, cyan, teal, green, light green, lime, yellow, amber, orange, deep orange, brown, grey, blue grey, black and white, plus custom for your own value.

  • accent: used for hovered links, buttons and scrollbars. The names are the same as for primary, without brown, grey, blue grey, black and white.

zensical.toml (single palette)
[project.theme.palette]
scheme = "slate"
primary = "indigo"
accent = "amber"

Light and dark toggle

When palette is written as a list ([[project.theme.palette]], an array of tables), each entry is one selectable palette and the header shows a toggle button. toggle.icon is an icon path from the bundled icon sets (the docs use lucide/sun, lucide/moon, lucide/toggle-left, lucide/toggle-right and lucide/sun-moon) and toggle.name is the accessible name, shown as a tooltip. The media key holds a media query. Entries with (prefers-color-scheme: light) and (prefers-color-scheme: dark) follow the operating system until the visitor uses the toggle, and the bare query (prefers-color-scheme) adds an "automatic" entry that follows the system.

zensical.toml (automatic, light and dark)
[[project.theme.palette]]
media = "(prefers-color-scheme)"
toggle.icon = "lucide/sun-moon"
toggle.name = "Switch to light mode"

[[project.theme.palette]]
media = "(prefers-color-scheme: light)"
scheme = "default"
primary = "indigo"
accent = "amber"
toggle.icon = "lucide/sun"
toggle.name = "Switch to dark mode"

[[project.theme.palette]]
media = "(prefers-color-scheme: dark)"
scheme = "slate"
primary = "indigo"
accent = "amber"
toggle.icon = "lucide/moon"
toggle.name = "Switch to system preference"

The Pocket Units build used the two-entry variant (light and dark, without the automatic entry). The generated page carried data-md-color-scheme, data-md-color-primary, data-md-color-accent and data-md-color-media attributes, for example data-md-color-scheme="default", data-md-color-primary="indigo" and data-md-color-accent="amber", and both media queries appeared in the markup so that the toggle can switch between them.

Custom colors and schemes

Setting primary = "custom" hands the color to your CSS. Register a stylesheet in extra_css and set the Material variables. The selector :root > * makes the rule win over the theme’s own declarations.

docs/stylesheets/extra.css
:root > * {
  --md-primary-fg-color:        #3f51b5;
  --md-primary-fg-color--light: #7986cb;
  --md-primary-fg-color--dark:  #303f9f;
}

.pocket-badge {
  display: inline-block;
  padding: 0 0.5em;
  border-radius: 0.2em;
  background: var(--md-accent-fg-color);
  color: var(--md-accent-bg-color);
}

A named scheme is a CSS rule on [data-md-color-scheme="name"], selected with scheme = "name". The docs example sets the primary variables inside [data-md-color-scheme="youtube"] and then theme.palette.scheme = "youtube". The dark scheme’s hue can be tuned with --md-hue (0 to 360) on [data-md-color-scheme="slate"].

Fonts

Zensical loads two Google Fonts families by default, selected by name in [project.theme]:

zensical.toml
[project.theme]
font.text = "Inter"
font.code = "JetBrains Mono"

font.text is the regular font and font.code the monospaced font. The official page does not state the default families. In the Pocket Units build these two settings produced a fonts.googleapis.com stylesheet link for both families (Inter weights 300, 400, 500 and 700 with italics, JetBrains Mono 400 and 700 with italics), plus a <style> block that sets --md-text-font and --md-code-font.

To meet privacy requirements, set font = false. The Google Fonts link is then not rendered and the site uses system fonts. In the Pocket Units build with font = false the page contained no googleapis reference and no --md-text-font declaration.

zensical.toml
[project.theme]
font = false

Self-hosted fonts

To use your own font files, disable Google Fonts, declare the face in a stylesheet listed in extra_css, and apply it through the CSS variables rather than font-family, because overriding font-family removes the system font fallback.

docs/stylesheets/extra.css
@font-face {
  font-family: "Pocket Sans";
  src: url("../fonts/pocket-sans.woff2") format("woff2");
  font-display: swap;
}

:root {
  --md-text-font: "Pocket Sans";
}

This font example was not executed: it needs a font file, and the name Pocket Sans is a placeholder. Font files placed under docs/ are copied to the site like other static files, so the relative url() must match their location below the stylesheet.

Header

The header is controlled by feature flags in [project.theme]:

  • header.autohide hides the header while the visitor scrolls down.

  • announce.dismiss adds a button to close the announcement bar. The bar stays hidden until its content changes.

The announcement bar itself has no setting: it is the empty announce block of the base template, filled by a template override (see Template overrides). The logo and the repository link are covered in Diagrams, Math and Icons.

Setting Effect

features = ["navigation.footer"]

Previous and next page links at the bottom of every page

hide = ["footer"] in page front matter

Removes the previous and next links from that page

[project] copyright = "..."

Copyright text, may contain HTML entities such as ©

[[project.extra.social]]

Social links with icon, link and an optional name (accessible title; the link domain is used when omitted)

[project.extra] generator = false

Hides the "made with" notice that is shown by default

zensical.toml (footer settings)
[project]
copyright = "Copyright &copy; 2026 Pocket Units contributors"

[project.theme]
features = ["header.autohide", "announce.dismiss", "navigation.footer"]

[project.extra]
generator = false

[[project.extra.social]]
icon = "fontawesome/brands/github"
link = "https://github.com/example/pocket-units"
name = "Pocket Units on GitHub"

Social links need a link with a URI scheme (mailto: works) and an icon that is a path inside the bundled icon sets (fontawesome/brands/github, fontawesome/brands/mastodon, fontawesome/solid/paper-plane, and so on). An icon path that does not exist fails the build. Changing the GitHub icon to fontawesome/brands/nope in the Pocket Units project produced this error:

RuntimeError: could not render include: error in "partials/footer.html" (in base.html:222): could not render include:
error in "partials/social.html" (in partials/footer.html:52): template not found: tried to include non-existing
template ".icons/fontawesome/brands/nope.svg" (in partials/social.html:24)

The official page asks users to keep the generator notice because it helps the project; the setting is nevertheless supported. With the settings above, the built page contained the GitHub link with title="Pocket Units on GitHub", the copyright text and no generator notice.

Extra CSS and JavaScript

Put the files under docs/ and list them with paths relative to docs_dir:

zensical.toml
[project]
extra_css = ["stylesheets/extra.css"]
extra_javascript = ["javascripts/extra.js"]

The same two keys exist at the top level of mkdocs.yml. The build copied both files to site/stylesheets/extra.css and site/javascripts/extra.js, linked the stylesheet in <head> after the theme’s own inline style block, and added the script at the end of <body>, after the theme bundle.

With instant navigation the browser does not reload the page, so code in a plain script runs once. To run code after every page load, subscribe to the document$ observable that the theme provides:

docs/javascripts/extra.js
document$.subscribe(function () {
  console.log("Pocket Units page loaded");
});

A file with the .mjs extension is loaded as a module (type="module"). An entry can also be a table with the keys path, type and async:

zensical.toml
[project]
extra_javascript = [
  "javascripts/extra.js",
  { path = "javascripts/app.mjs" },
  { path = "javascripts/analytics.js", type = "module", async = true },
]

Module detection by file extension works only for plain string entries, so combining async with a module requires type = "module" explicitly. This table form was not executed in the Pocket Units build, which used only the plain string entry.

Template overrides with custom_dir

Zensical renders the HTML around the content (header, navigation, footer) with MiniJinja, a Rust template engine inspired by Jinja. The Markdown file supplies only the main content. The default page template is main.html, which extends base.html, and base.html includes the files of the partials directory. A page can choose another template through the template front matter key.

Configuring the overrides directory

[project.theme]
custom_dir = "overrides"

The path is resolved relative to the configuration file, not to docs_dir, so overrides/ sits next to zensical.toml. A file in custom_dir that has the same relative path as a built-in template replaces it.

The built-in theme contains these templates and partials, which are the names you can override:

  • root templates: 404.html, base.html, main.html

  • partials: actions, alternate, comments (empty by default), consent, content, copyright, feedback, footer, header, icons, language, logo, nav, nav-item, palette, progress, search, social, source, source-file, tabs, tabs-item, tags, toc, toc-item, top

  • sub-folders: partials/integrations (with analytics) and partials/languages

  • static folders: .icons/ and assets/

Overriding blocks

Overriding blocks is the recommended technique, because only the blocks you name change and everything else follows upgrades. Create overrides/main.html, extend base.html and redefine blocks. The theme provides these blocks: analytics, announce, config, container, content, extrahead, fonts, footer, header, hero, htmltitle, libs, outdated, scripts, site_meta, site_nav, styles and tabs. announce and extrahead are empty by default, and {{ super() }} keeps the original content of a block.

overrides/main.html
{% extends "base.html" %}

{% block announce %}
  Pocket Units 1.0 is out: <a href="{{ 'api/' | url }}">read the API reference</a>.
{% endblock %}

{% block extrahead %}
  {{ super() }}
  <meta name="pocket-units:audience" content="developers">
{% endblock %}

In the built site/index.html the announcement text appeared inside the announcement bar, with the dismiss button added by announce.dismiss and the link rendered as ./api/. The <meta> tag appeared at the end of <head>.

Overriding partials

To replace a whole partial, create the same file name and relative path in custom_dir. This example replaces the copyright partial and keeps the configured copyright text:

overrides/partials/copyright.html
<div class="md-copyright">
  {% if config.copyright %}
    <div class="md-copyright__highlight">{{ config.copyright }}</div>
  {% endif %}
  <div class="md-copyright__highlight">Built for the Pocket Units docs</div>
</div>

The built page contained both lines in the footer. A partial override copies code that you then own; when the built-in partial changes in a new release, the override does not follow. Prefer a block override when one block is enough.

Custom templates for single pages

A template whose name does not collide with a built-in one is chosen per page with front matter. The template extends main.html and redefines the content block:

overrides/landing.html
{% extends "main.html" %}
{% block content %}<p class="pocket-badge">Landing</p>{{ super() }}{% endblock %}
docs/index.md
---
template: landing.html
---
# Pocket Units

Convert lengths.

The built site/index.html contained the pocket-badge paragraph above the page content.

What MiniJinja cannot do

MiniJinja is not Python. There is no Python interpreter in the build, so templates cannot call arbitrary Python functions or methods. Use MiniJinja filters and tests instead. In the Pocket Units project, a block that called "abc".upper() stopped the build with an error, while the filter form "abc" | upper is the supported spelling:

RuntimeError: unknown method: string has no method named upper (in main.html:2)

Calling a method of an undefined variable fails the same way (unknown method: undefined has no method named bar). Overrides written for Material for MkDocs 9.6.18 or later are reported to work, because the Material templates were adapted. An override that depends on a Python function without a filter equivalent has to be replaced by a Zensical module or another supported mechanism; for example, there is no place to run a hook yet, see Migrating from Material for MkDocs.

Custom error pages

Put a 404.html in custom_dir. It replaces the built-in error page; a web server can serve it as its not-found document. Extending main.html keeps the header, navigation and footer:

overrides/404.html
{% extends "main.html" %}

{% block content %}
  <h1>Unit not found</h1>
  <p>This page does not exist. Try the <a href="{{ 'api/' | url }}">API reference</a>.</p>
{% endblock %}

The build wrote site/404.html with the new content. Whether a server uses the file as its not-found document depends on the server; the documentation only says that it can serve as the fallback. Links in the page use the url filter, as above, so that they resolve relative to the site root.

Running the complete example

The files used on this page form one project: zensical.toml, docs/index.md, docs/api.md, the two files under docs/stylesheets and docs/javascripts, and the four files in overrides/.

Output of the build
zensical build --clean
Build started
No issues found
Build finished in 0.35s

Pitfalls and version notes

  • palette is a table when you set one palette and an array of tables when you want a toggle. TOML does not allow palette to be defined both ways in one file.

  • custom_dir is relative to the config file, extra_css and extra_javascript are relative to docs_dir.

  • An invalid icon path in toggle.icon or social fails the build with a template not found error that names the missing icon file under .icons/.

  • After editing overrides or after an upgrade, rebuild with zensical build --clean to discard cached output.

  • Overrides that call Python functions fail at build time. Convert them to filters or tests, or remove them.

  • Themes can be packaged as Python distributions with the mkdocs.themes entry point and an optional mkdocs_theme.yml (extends: material is kept for compatibility). The documentation also announces a future component system for reuse below the theme level, so template overrides may change in later 0.x releases.