Customising Material for MkDocs
Backdrop¶
First let's talk about the mkdocs-material
you get then you sudo pacman -S
mkdocs-material
. Examine the output of pacman -Ql mkdocs-material
(only showing 10 of the 10,000+ lines):
mkdocs-material /usr/
mkdocs-material /usr/lib/
mkdocs-material /usr/lib/python3.13/
mkdocs-material /usr/lib/python3.13/site-packages/
mkdocs-material /usr/lib/python3.13/site-packages/material/
mkdocs-material /usr/lib/python3.13/site-packages/material/__init__.py
mkdocs-material /usr/lib/python3.13/site-packages/material/__pycache__/
mkdocs-material /usr/lib/python3.13/site-packages/material/__pycache__/__init__.cpython-313.opt-1.pyc
mkdocs-material /usr/lib/python3.13/site-packages/material/__pycache__/__init__.cpython-313.pyc
mkdocs-material /usr/lib/python3.13/site-packages/material/extensions/
...
Almost all of the installed files are under
/usr/lib/python3.13/site-packages/material/
. This directory is the material
theme.
>>> import material
>>> print(material.__file__)
/usr/lib/python3.13/site-packages/material/__init__.py
This material
is the result of compilation process that takes the scss and js
files in the src/templates
directory of the mkdocs-material project:
https://github.com/squidfunk/mkdocs-material/tree/master/src/templates.
It is this src/templates
folder you need to work with to really customize the
material for mkdocs theme, but the arch mkdocs-material
doesn't have it
(which is the way it should be).
Hopefully this gives you some context behind why we are are cloning the mkdocs-material project, using pip/venv rather than the pacman/arch.
First setup¶
reference: https://squidfunk.github.io/mkdocs-material/customization/#environment-setup-material-for-mkdocs
Every new mkdocs project (which will ofcourse be a website) in our workflow will involve two directories:
vector@ArchLinuxVS ~ $ tree -L 1 /home/vector/development/mkdocs
/home/vector/development/mkdocs
├── mkdocs-material
└── site
...where mkdocs-material
is the result of git clone --depth 1 https://github.com/squidfunk/mkdocs-material
.
$ cd mkdocs-material
$ python -m venv venv
$ source venv/bin/activate
$ pip install -e ".[recommended]"
$ hash -r
$ pip install nodeenv
$ hash -r
$ nodeenv -p -n lts
$ npm install
Now take a step back, into mkdocs
folder and create the site with mkdocs
command, but first make sure $ which mkdocs
command points to the binary in
venv
(this was installed with pip install -e ".[recommended]"
earlier).
$ cd ..
$ which mkdocs
/home/vector/development/mkdocs/mkdocs-material/venv/bin/mkdocs
$ mkdocs new site
mkdocs vs mkdocs-material
mkdocs-material is a theme for mkdocs. After installing mkdocs-material you
can import material
in python.
Now I edited mkdocs/mkdocs-material/tools/build/_/index.ts
so that any
modication made to templates in src directory will lead to a recompiling and
output produced in mkdocs/mkdocs-material/material/templates
.
mkdocs/mkdocs-material/tools/build/_/index.ts | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 |
|
Now do:
The template wills will be generated in mkdocs-material/material/templates
folder.
templates $ ls
404.html base.html blog-post.html __init__.py mkdocs_theme.yml __pycache__
assets blog.html fragments main.html partials redirect.html
Take note of the overrides
directory in src
.
The contents of this directory shows a sample site implementation using mkdocs material.
vector@ArchLinuxVS ~/development/mkdocs/mkdocs-material/src $ tree overrides
overrides
├── assets
│  ├── javascripts
│  │  ├── components
│  │  │  ├── _
│  │  │  │  └── index.ts
│  │  │  ├── iconsearch
│  │  │  │  ├── _
│  │  │  │  │  └── index.ts
│  │  │  │  ├── index.ts
│  │  │  │  ├── query
│  │  │  │  │  └── index.ts
│  │  │  │  └── result
│  │  │  │  └── index.ts
│  │  │  ├── index.ts
│  │  │  └── sponsorship
│  │  │  └── index.ts
│  │  ├── custom.ts
│  │  ├── integrations
│  │  │  ├── analytics
│  │  │  │  └── index.ts
│  │  │  └── index.ts
│  │  └── templates
│  │  ├── iconsearch
│  │  │  └── index.tsx
│  │  ├── index.ts
│  │  └── sponsorship
│  │  └── index.tsx
│  └── stylesheets
│  ├── custom
│  │  ├── layout
│  │  │  ├── _banner.scss
│  │  │  ├── _hero.scss
│  │  │  ├── _iconsearch.scss
│  │  │  └── _sponsorship.scss
│  │  └── _typeset.scss
│  └── custom.scss
├── home.html
├── hooks
│  ├── shortcodes.py
│  ├── translations.html
│  └── translations.py
└── main.html
Now create a symbolic link to the mkdocs/material/templates
in the new site you are
developing
cd site
ln -s /home/vector/development/mkdocs/mkdocs-material/material/templates /home/vector/development/mkdocs/sreema/overrides
Its probably not best practice to use the templates folder like this. But I'm at the point where I value control and I wannt see for myself if I can make really customized sites with mkdocs.
Now let's develop the site.
systemd automation¶
Update: 2025-06-18
During development I think its better to run these commands from the shell rather than automate with systemd. This way you'll get real time updates during development.
To make your life easier, consider making these systemd files to autostart npm run
and mkdocs serve
commands.
[Unit]
Description=Start the mkdocs server
[Service]
Environment=PATH=/home/vector/devel/sreema_filaments/mkdocs-material/venv/bin:/usr/local/bin:/usr/bin:/bin
WorkingDirectory=/home/vector/devel/sreema_filaments/mkdocs-material
ExecStart=/home/vector/devel/sreema_filaments/mkdocs-material/venv/bin/npm start
[Install]
WantedBy=default.target
[Unit]
Description=Start the mkdocs server
[Service]
Environment=PATH=/home/vector/devel/sreema_filaments/mkdocs-material/venv/bin:/usr/local/bin:/usr/bin:/bin
WorkingDirectory=/home/vector/devel/sreema_filaments/site
ExecStart=/home/vector/devel/sreema_filaments/mkdocs-material/venv/bin/mkdocs serve -a 127.0.0.1:7000 --watch-theme
[Install]
WantedBy=default.target
Confirm each works and enable them:
systemctl --user start sreema-npm
systemctl --user enable sreema-npm
systemctl --user start sreema-site
systemctl --user enable sreema-site
Modifying logo appearance¶
Since I was dealing with an non-typical logo (3 png format logos laid side by side), I want it to take up the entire height of the header. To achieve this all I had to do was edit the _header.scss file:
Info
Only the relevant section in the _header.scss file. Edit to this, keep the rest.
&__button {
// General button styles
padding: px2rem(8px);
margin: px2rem(4px);
// Logo-specific styles
&.md-logo {
padding: px2rem(0px); // This OVERRIDES the above padding
margin: 0 0 0 px2rem(4px); // This OVERRIDES the above margin
}
}
Resizing logo dynamically¶
The default way to add your site logo in mkdocs works well only if its single svg logo, but using it when you have multiple logos, in png format it just doesn't look right. Therefore I tried to get the logo to appear bigger when the title displayed in the bar is the site_title, and make it smaller when its showing the headings.
import "focus-visible"
import {
EMPTY,
NEVER,
Observable,
Subject,
defer,
delay,
filter,
map,
merge,
mergeWith,
shareReplay,
switchMap
} from "rxjs"
import { configuration, feature } from "./_"
import {
at,
getActiveElement,
getOptionalElement,
requestJSON,
setLocation,
setToggle,
watchDocument,
watchKeyboard,
watchLocation,
watchLocationTarget,
watchMedia,
watchPrint,
watchScript,
watchViewport
} from "./browser"
import {
getComponentElement,
getComponentElements,
mountAnnounce,
mountBackToTop,
mountConsent,
mountContent,
mountDialog,
mountHeader,
mountHeaderTitle,
mountLogoSize,
mountPalette,
mountProgress,
mountSearch,
mountSearchHiglight,
mountSidebar,
mountSource,
mountTableOfContents,
mountTabs,
watchHeader,
watchMain
} from "./components"
import {
SearchIndex,
setupClipboardJS,
setupInstantNavigation,
setupVersionSelector
} from "./integrations"
import {
patchEllipsis,
patchIndeterminate,
patchScrollfix,
patchScrolllock
} from "./patches"
import "./polyfills"
/* ----------------------------------------------------------------------------
* Functions - @todo refactor
* ------------------------------------------------------------------------- */
/**
* Fetch search index
*
* @returns Search index observable
*/
function fetchSearchIndex(): Observable<SearchIndex> {
if (location.protocol === "file:") {
return watchScript(
`${new URL("search/search_index.js", config.base)}`
)
.pipe(
// @ts-ignore - @todo fix typings
map(() => __index),
shareReplay(1)
)
} else {
return requestJSON<SearchIndex>(
new URL("search/search_index.json", config.base)
)
}
}
/* ----------------------------------------------------------------------------
* Application
* ------------------------------------------------------------------------- */
/* Yay, JavaScript is available */
document.documentElement.classList.remove("no-js")
document.documentElement.classList.add("js")
/* Set up navigation observables and subjects */
const document$ = watchDocument()
const location$ = watchLocation()
const target$ = watchLocationTarget(location$)
const keyboard$ = watchKeyboard()
/* Set up media observables */
const viewport$ = watchViewport()
const tablet$ = watchMedia("(min-width: 960px)")
const screen$ = watchMedia("(min-width: 1220px)")
const print$ = watchPrint()
/* Retrieve search index, if search is enabled */
const config = configuration()
const index$ = document.forms.namedItem("search")
? fetchSearchIndex()
: NEVER
/* Set up Clipboard.js integration */
const alert$ = new Subject<string>()
setupClipboardJS({ alert$ })
/* Set up progress indicator */
const progress$ = new Subject<number>()
/* Set up instant navigation, if enabled */
if (feature("navigation.instant"))
setupInstantNavigation({ location$, viewport$, progress$ })
.subscribe(document$)
/* Set up version selector */
if (config.version?.provider === "mike")
setupVersionSelector({ document$ })
/* Always close drawer and search on navigation */
merge(location$, target$)
.pipe(
delay(125)
)
.subscribe(() => {
setToggle("drawer", false)
setToggle("search", false)
})
/* Set up global keyboard handlers */
keyboard$
.pipe(
filter(({ mode }) => mode === "global")
)
.subscribe(key => {
switch (key.type) {
/* Go to previous page */
case "p":
case ",":
const prev = getOptionalElement<HTMLLinkElement>("link[rel=prev]")
if (typeof prev !== "undefined")
setLocation(prev)
break
/* Go to next page */
case "n":
case ".":
const next = getOptionalElement<HTMLLinkElement>("link[rel=next]")
if (typeof next !== "undefined")
setLocation(next)
break
/* Expand navigation, see https://bit.ly/3ZjG5io */
case "Enter":
const active = getActiveElement()
if (active instanceof HTMLLabelElement)
active.click()
}
})
/* Set up patches */
patchEllipsis({ viewport$, document$ })
patchIndeterminate({ document$, tablet$ })
patchScrollfix({ document$ })
patchScrolllock({ viewport$, tablet$ })
/* Set up header and main area observable */
const header$ = watchHeader(getComponentElement("header"), { viewport$ })
const main$ = document$
.pipe(
map(() => getComponentElement("main")),
switchMap(el => watchMain(el, { viewport$, header$ })),
shareReplay(1)
)
/* Set up control component observables */
const control$ = merge(
/* Consent */
...getComponentElements("consent")
.map(el => mountConsent(el, { target$ })),
/* Dialog */
...getComponentElements("dialog")
.map(el => mountDialog(el, { alert$ })),
/* Color palette */
...getComponentElements("palette")
.map(el => mountPalette(el)),
/* Progress bar */
...getComponentElements("progress")
.map(el => mountProgress(el, { progress$ })),
/* Search */
...getComponentElements("search")
.map(el => mountSearch(el, { index$, keyboard$ })),
/* Repository information */
...getComponentElements("source")
.map(el => mountSource(el))
)
/* Set up content component observables */
const content$ = defer(() => merge(
/* Announcement bar */
...getComponentElements("announce")
.map(el => mountAnnounce(el)),
/* Content */
...getComponentElements("content")
.map(el => mountContent(el, { viewport$, target$, print$ })),
/* Search highlighting */
...getComponentElements("content")
.map(el => feature("search.highlight")
? mountSearchHiglight(el, { index$, location$ })
: EMPTY
),
/* Header */
...getComponentElements("header")
.map(el => mountHeader(el, { viewport$, header$, main$ })),
/* Header title */
...getComponentElements("header-title")
.map(el => mountHeaderTitle(el, { viewport$, header$ })),
/* Header logo */ // <-- Add this
...getComponentElements("logo")
.map(el => mountLogoSize(el, { viewport$, header$ })),
/* Sidebar */
...getComponentElements("sidebar")
.map(el => el.getAttribute("data-md-type") === "navigation"
? at(screen$, () => mountSidebar(el, { viewport$, header$, main$ }))
: at(tablet$, () => mountSidebar(el, { viewport$, header$, main$ }))
),
/* Navigation tabs */
...getComponentElements("tabs")
.map(el => mountTabs(el, { viewport$, header$ })),
/* Table of contents */
...getComponentElements("toc")
.map(el => mountTableOfContents(el, {
viewport$, header$, main$, target$
})),
/* Back-to-top button */
...getComponentElements("top")
.map(el => mountBackToTop(el, { viewport$, header$, main$, target$ }))
))
/* Set up component observables */
const component$ = document$
.pipe(
switchMap(() => content$),
mergeWith(control$),
shareReplay(1)
)
/* Subscribe to all components */
component$.subscribe()
/* ----------------------------------------------------------------------------
* Exports
* ------------------------------------------------------------------------- */
window.document$ = document$ /* Document observable */
window.location$ = location$ /* Location subject */
window.target$ = target$ /* Location target observable */
window.keyboard$ = keyboard$ /* Keyboard observable */
window.viewport$ = viewport$ /* Viewport observable */
window.tablet$ = tablet$ /* Media tablet observable */
window.screen$ = screen$ /* Media screen observable */
window.print$ = print$ /* Media print observable */
window.alert$ = alert$ /* Alert subject */
window.progress$ = progress$ /* Progress indicator subject */
window.component$ = component$ /* Component observable */
<!-- Determine classes -->
{% set class = "md-header" %}
{% if "navigation.tabs.sticky" in features %}
{% set class = class ~ " md-header--shadow md-header--lifted" %}
{% elif "navigation.tabs" not in features %}
{% set class = class ~ " md-header--shadow" %}
{% endif %}
<!-- Header -->
<header class="{{ class }}" data-md-component="header">
<nav
class="md-header__inner md-grid"
aria-label="{{ lang.t('header') }}"
>
<!-- Link to home -->
<a
href="{{ config.extra.homepage | d(nav.homepage.url, true) | url }}"
title="{{ config.site_name | e }}"
class="md-header__button md-logo"
aria-label="{{ config.site_name }}"
data-md-component="logo"
>
{% include "partials/logo.html" %}
</a>
<!-- Button to open drawer -->
<label class="md-header__button md-icon" for="__drawer">
{% set icon = config.theme.icon.menu or "material/menu" %}
{% include ".icons/" ~ icon ~ ".svg" %}
</label>
<!-- Header title -->
<div class="md-header__title" data-md-component="header-title">
<div class="md-header__ellipsis">
<div class="md-header__topic">
<span class="md-ellipsis">
{{ config.site_name }}
</span>
</div>
<div class="md-header__topic" data-md-component="header-topic">
<span class="md-ellipsis">
{% if page.meta and page.meta.title %}
{{ page.meta.title }}
{% else %}
{{ page.title }}
{% endif %}
</span>
</div>
</div>
</div>
<!-- Color palette toggle -->
{% if config.theme.palette %}
{% if not config.theme.palette is mapping %}
{% include "partials/palette.html" %}
{% endif %}
{% endif %}
<!-- User preference: color palette -->
{% if not config.theme.palette is mapping %}
{% include "partials/javascripts/palette.html" %}
{% endif %}
<!-- Site language selector -->
{% if config.extra.alternate %}
{% include "partials/alternate.html" %}
{% endif %}
<!-- Button to open search modal -->
{% if "material/search" in config.plugins %}
{% set search = config.plugins["material/search"] | attr("config") %}
<!-- Check if search is actually enabled - see https://t.ly/DT_0V -->
{% if search.enabled %}
<label class="md-header__button md-icon" for="__search">
{% set icon = config.theme.icon.search or "material/magnify" %}
{% include ".icons/" ~ icon ~ ".svg" %}
</label>
<!-- Search interface -->
{% include "partials/search.html" %}
{% endif %}
{% endif %}
<!-- Repository information -->
{% if config.repo_url %}
<div class="md-header__source">
{% include "partials/source.html" %}
</div>
{% endif %}
</nav>
<!-- Navigation tabs (sticky) -->
{% if "navigation.tabs.sticky" in features %}
{% if "navigation.tabs" in features %}
{% include "partials/tabs.html" %}
{% endif %}
{% endif %}
</header>
import {
EMPTY,
Observable,
Subject,
defer,
distinctUntilKeyChanged,
finalize,
map,
tap
} from "rxjs"
import {
Viewport,
getElementSize,
getOptionalElement,
watchViewportAt
} from "~/browser"
import { Component } from "../../_"
import { Header } from "../_"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Header
*/
export interface HeaderTitle {
active: boolean /* Header title is active */
}
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Watch options
*/
interface WatchOptions {
viewport$: Observable<Viewport> /* Viewport observable */
header$: Observable<Header> /* Header observable */
}
/**
* Mount options
*/
interface MountOptions {
viewport$: Observable<Viewport> /* Viewport observable */
header$: Observable<Header> /* Header observable */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Watch header title
*
* @param el - Heading element
* @param options - Options
*
* @returns Header title observable
*/
export function watchHeaderTitle(
el: HTMLElement, { viewport$, header$ }: WatchOptions
): Observable<HeaderTitle> {
return watchViewportAt(el, { viewport$, header$ })
.pipe(
map(({ offset: { y } }) => {
const { height } = getElementSize(el)
return {
active: y >= height
}
}),
distinctUntilKeyChanged("active")
)
}
/**
* Mount header title
*
* This function swaps the header title from the site title to the title of the
* current page when the user scrolls past the first headline.
*
* @param el - Header title element
* @param options - Options
*
* @returns Header title component observable
*/
export function mountLogoSize(
el: HTMLElement, options: MountOptions
): Observable<Component<HeaderTitle>> {
return defer(() => {
const push$ = new Subject<HeaderTitle>()
push$.subscribe({
/* Handle emission */
next({ active }) {
el.classList.toggle("md-header__title--active", active)
// Add this line to also toggle the logo compact state
const logo = document.querySelector(".md-header__button.md-logo")
if (logo) {
logo.classList.toggle("md-header__logo--compact", active)
}
},
/* Handle complete */
complete() {
el.classList.remove("md-header__title--active")
// Add this line to clean up the logo state on component destruction
const logo = document.querySelector(".md-header__button.md-logo")
if (logo) {
logo.classList.remove("md-header__logo--compact")
}
}
})
/* Obtain headline, if any */
const heading = getOptionalElement(".md-content h1")
if (typeof heading === "undefined")
return EMPTY
/* Create and return component */
return watchHeaderTitle(heading, options)
.pipe(
tap(state => push$.next(state)),
finalize(() => push$.complete()),
map(state => ({ ref: el, ...state }))
)
})
}
Site title transition¶
// Header title
&__title {
flex-grow: 1;
height: px2rem(48px);
margin-inline: px2rem(20px) px2rem(8px);
font-size: px2rem(28px);
line-height: px2rem(28px);
// Add transition for line-height changes
transition: line-height 400ms cubic-bezier(0.1, 0.7, 0.1, 1);
// Header title in active state, i.e. page title is visible
&--active .md-header__topic {
z-index: -1;
pointer-events: none;
opacity: 0;
transition:
transform 400ms cubic-bezier(1, 0.7, 0.1, 0.1),
opacity 150ms;
transform: translateX(px2rem(-25px));
[dir="rtl"] & {
transform: translateX(px2rem(25px));
}
+ .md-header__topic {
line-height: px2rem(48px);
z-index: 0;
font-size: px2rem(18px);
pointer-events: initial;
opacity: 1;
transition:
transform 400ms cubic-bezier(0.1, 0.7, 0.1, 1),
opacity 150ms,
line-height 400ms cubic-bezier(0.1, 0.7, 0.1, 1); // Add line-height transition here too
transform: translateX(0);
}
}
> .md-header__ellipsis {
position: relative;
width: 100%;
height: 100%;
}
}