Guides

Writing a theme

The theme settings cover colors and layout options. To go further, replace single files of the built-in theme, add your own CSS, or write a theme from scratch. All of it happens on the server.

Three levels

You want toDo this
Tweak styles: fonts, spacing, colors beyond accentAdd a CSS file with an overrides folder
Change one part of the page, like the note header or the footerOverride that template
Change a word, or translate the theme to another languageAdd a word file
A completely different lookWrite a new theme

Overriding theme files

An overrides folder has the same layout as a theme. Each file in it replaces the theme's file with the same path, and new files are added. Everything else comes from the theme, so you only copy what you change.

[[site]]
id              = "garden"
theme           = "garden"
theme_overrides = "/etc/kvist/theme"   # a folder on the server

With Docker, put the folder next to docker-compose.yml and mount it into the container:

services:
  kvist:
    volumes:
      - kvist-data:/data
      - ./kvist.toml:/etc/kvist/kvist.toml:ro
      - ./theme:/etc/kvist/theme:ro        # add this line

Then run docker compose up -d and publish, so the site is rebuilt with your files.

The built-in theme's files are in the repository under themes/garden/. Copy the one you want to change into your overrides folder at the same path. The main parts:

FileWhat it renders
templates/base.htmlThe page skeleton: <head>, the three columns, scripts
templates/note.htmlA note: header, content, backlinks, contents and graph
templates/home.htmlThe home page
templates/partials/nav.htmlThe menu column
templates/partials/list.htmlThe middle list column
templates/partials/note-parts.htmlCards, growth stages, backlinks, contents list
templates/partials/head-meta.htmlLink-preview tags in <head>
templates/partials/head-extra.htmlEmpty: your own additions at the end of <head>
static/style.cssAll styles

Overridden files don't update

When kvist updates, your copies stay as they are. Override as few files as you can, and compare them with the new versions after an update.

Adding your own CSS

The lightest way to restyle the site is an extra stylesheet loaded after the theme's own, so you don't copy style.css at all. Two small files in your overrides folder do it:

:root {
  --font: "Iowan Old Style", Georgia, serif;   /* the theme's styles use CSS variables */
}
.note-header h1 { letter-spacing: 0; }
{{define "head-extra" -}}
<link rel="stylesheet" href="{{asset "custom.css"}}">
{{- end}}

The theme includes head-extra at the very end of <head> and leaves it empty, just for this. Your stylesheet loads after the theme's, so your rules win. {{asset "custom.css"}} gives the file's address, with a fingerprint that changes when the file does, so browsers never use an old copy. The same file is the place for extra meta tags or a script.

Useful variables to change: --bg, --fg, --muted, --border, --pane, --font, --font-mono and --radius. For dark mode, set them again under :root[data-theme="dark"] and in a @media (prefers-color-scheme: dark) block, as style.css does.

Changing the theme's words

The menu, dates and search speak the site's language. The theme keeps its words in i18n/<language>.toml, one file per language; the built-in garden theme has English, Swedish, German, French and Spanish. To change a word, or to add a language, put a file with the same name in your overrides folder. It only needs the words you change, the rest still comes from the theme:

planted = "Sådd"
tended  = "Ompysslad"

For a language the theme doesn't have, start from a copy of themes/garden/i18n/en.toml in the repository, name it after the language code (nb.toml, pt.toml, …) and translate it. Words you leave out stay in English.

Previewing while you work

Run the preview server on your computer with a copy of your server's config, pointing theme_overrides (or theme) at your local folder:

kvist dev --dir ~/Documents/MyVault --config kvist.toml

It rebuilds and reloads the browser whenever a note or a theme file changes. A mistake in a template shows at the bottom of the page, while the last working version stays up. See the command line for how to get kvist.

A new theme

A theme is a folder:

mytheme/
  theme.toml
  templates/
    base.html          the page skeleton; fills {{block "main"}} and {{block "aside"}}
    home.html          the home page, /
    note.html          every note
    folder.html        folders without a folder note
    tag.html           /tags/<tag>/
    tags.html          /tags/
    404.html           the "not found" page
    partials/*.html    shared {{define}} blocks
  static/              CSS, JavaScript, fonts, images
  i18n/                the theme's words, one file per language (optional)
name    = "mytheme"
version = "1.0.0"
license = "MIT"
model   = [1]              # content model versions this theme understands

[params]                   # defaults; sites change them in the settings note
accent = "#3f7d4e"

Put the folder in themes_dir (default: themes, next to where kvist runs) and set theme = "mytheme", or give a path: theme = "/etc/kvist/mytheme". In Docker, mount the folder like the overrides above. A folder in themes_dir named garden replaces the built-in one.

The quickest start is to copy themes/garden from the repository, rename it, and change it from there.

Templates

Templates use Go's html/template, which escapes text by context, so note titles can't break your HTML. Each page template is combined with base.html and all partials, and base.html is run with a page:

Field
.Kindhome, note, folder, tag, tags or 404
.Title, .URL, .DescriptionFor <title>, links and meta tags. Folder and tag pages get a description from what they list
.CanonicalThe page's preferred address, for <link rel="canonical">: the same as .URL, except on the home note's own page, which points to the home page
.NoteThe note, on note pages (and on the home page if it uses a note)
.Folder, .TagThe folder or tag, on their pages
.SiteEverything published (below)
.Features.Math, .Mermaid, .Code: load KaTeX, Mermaid and highlighting styles only when the page needs them
.SocialImageOn note pages without an image (or with an SVG one, which social media don't show): the address of a generated preview picture, if the theme turns on social_images in its [params]
.ImageThe picture to use for og:image: the note's image, else .SocialImage, else the site's image; empty if none can be shown. .ImageWidth and .ImageHeight are its size, 0 if unknown
.NoIndexTrue when search engines should leave the page out: unlisted notes, the 404 page and tag pages with fewer notes than tag_min_notes

A minimal base.html and note.html:

<!doctype html>
<html lang="{{.Site.Config.Language}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} · {{.Site.Config.Title}}</title>
<link rel="stylesheet" href="{{asset "style.css"}}">
{{- if .Features.Code}}<link rel="stylesheet" href="{{asset "syntax.css"}}">{{end}}
</head>
<body>
<header><a href="/">{{.Site.Config.Title}}</a></header>
<main>{{block "main" .}}{{end}}</main>
</body>
</html>
{{define "main" -}}
<article>
  <h1>{{.Note.Title}}</h1>
  <p>{{dateFormat "2 January 2006" .Note.Updated}}</p>
  {{.Note.Content}}
  {{- with .Note.Backlinks}}
  <h2>Links to this note</h2>
  <ul>{{range .}}<li><a href="{{.Source.URL}}">{{.Source.Title}}</a></li>{{end}}</ul>
  {{- end}}
</article>
{{- end}}

What a theme receives

ObjectFields
.SiteNotes (every published note, sorted by address; private notes are never included, so there's nothing to filter), Root (the site's root folder; its subfolders are the top-level folders), AllTags, Tags (top-level), Home, Graph, Config
.Site.ConfigTitle, Description, BaseURL, Language, Author, PrimaryNav and Nav (the settings note's primary_links and secondary_links, each a list of Title, URL), Params, Image, Favicon, Profile (Avatar, Bio, Links), Analytics (Provider, ID, Script, Endpoint)
A noteTitle, URL, Description, Content (HTML), Created, Updated, Tags, Aliases, TOC, Links, Backlinks (Source, Context), Related (notes with the most in common), Folder, Params, Image, ImageThumb and ImageCard (small copies for lists and cards), Unlisted, Canvas (the page is a canvas, its Content a board of cards), WordCount, ReadingTime
A folderName, Path, URL, Notes (without unlisted notes), Children, Index (its folder note), Parent, Sort, Icon and IconText (from the folder note's icon: an icon name to draw, or an emoji to show as it is; at most one is set)
A tagName (e.g. projects/kvist), URL, Notes (including child tags' notes), Children, Parent
A heading (TOC)Level, Text, ID, Children

The built-in theme draws a folder's icon with the folder-icon template in templates/partials/nav.html, and the icons themselves in the icon template next to it. To add icon names of your own, copy that file into your overrides folder and add a branch to icon. See Folder icons for the names it already knows.

A note's Params holds only the properties listed in the server's expose_frontmatter. To see all the data for a vault, write it out as JSON:

kvist build --dir ~/Documents/MyVault --config kvist.toml --out /tmp/site --emit-model model.json

The content model reference describes every field in detail.

Functions

FunctionExample
asset{{asset "style.css"}}Address of a file in static/, fingerprinted
param{{param "accent"}}A theme setting: the site's value, else the theme's default
recent{{range recent 10}}The most recently updated notes, without unlisted ones, the home note or folder notes
newest{{range newest 3 .Tag.Notes}}Any list of notes, most recently updated first (0 for all)
dateFormat{{dateFormat "2 Jan 2006" .Note.Updated}}Format a date: write 2 January 2006 the way you want dates to look
isoDate{{isoDate .Note.Created}}For <time datetime>
absURL{{absURL .URL}}Full address with the site's domain
structuredData{{structuredData .}}The page described for search engines (schema.org JSON-LD), for <script type="application/ld+json">; empty on the 404 page
markdownify{{markdownify (str (param "footer"))}}Markdown to HTML
truncate{{truncate 160 .Description}}Shorten at a word boundary
hashart-{{hash .URL 8}}A number from 0 to n−1 picked from a text, the same in every build, e.g. to give each note its own color
default{{default "Untitled" .Title}}A fallback for empty values
dict{{template "card" (dict "Note" . "Big" true)}}Pass several values to a template
json{{json .Site.Graph}}A value as JSON
t{{t "search"}}, {{t "notes" 3}}A word in the site's language, from i18n/ (above); plural forms pick by the count
i18n{{json (i18n "script")}}A table of words, for a script
propertyValues{{range propertyValues "2 Jan 2006" .Note.Params.authors}}A property as text: one per list item, dates formatted, [[links]] as their text
Text helperslower, upper, title, join, replace, contains, hasPrefix, str
Numbersadd, sub

Templates can't read files or reach the network.

Styling note content

A note's Content is ready-made HTML. These are the classes to style:

ClassWhat
a.internal-link, a.external-linkA link to another published note, or to another website
span.link-unpublishedA link to a private or missing note. Style it as plain text, so visitors can't tell that a private note exists.
div.embed, .embed-title, .embed-contentAn embedded note or section
div.callout[data-callout="tip"], .callout-title, .callout-contentCallouts; foldable ones use <details>
a.tag, span.tag-hiddenTags; the publish tags are hidden ones
div.code-block[data-lang], pre.chromaHighlighted code; colors come from syntax.css
.code-title, .line.hl, .lnA code block's title, its highlighted lines and its line numbers
div.canvas, .canvas-board, .canvas-node, svg.canvas-edgesA canvas: cards placed by inline styles, arrows in SVG. The content model lists every class
.math-inline, .math-displayMath, for KaTeX to render in the browser
pre.mermaidA diagram, for Mermaid to draw
img.align-left, .align-right, .align-centerImages placed with |left, |right or |center
mark, iframe.embed-pdf, iframe.embed-video, audio, videoHighlights, embedded files and YouTube or Vimeo players

Every build also writes /search-index.json and /graph.json, which a theme's scripts can load for search and graphs, plus /index.xml (RSS), /sitemap.xml and /robots.txt. None of them include unlisted notes. Redirects for moved notes are added by the server, whatever the theme: a small page at each old address, and a map the built-in server reads from disk (_kvist/redirects.json, not served).

Good theme manners

  • Work without JavaScript. Use scripts for extras like search, the graph and the theme toggle.
  • Keep private notes invisible. Style .link-unpublished like normal text, with no “locked” icons or hints.
  • Respect unlisted notes. The site's lists leave them out already; on their own page, add <meta name="robots" content="noindex"> when .NoIndex is true (it is for unlisted notes).
  • Describe pages for search engines. Use .Canonical for the canonical link, .Image for og:image and structuredData for JSON-LD, as the built-in theme's partials/head-meta.html does.
  • Load heavy things only when needed. Check .Features.Math and .Features.Mermaid before loading KaTeX or Mermaid.
  • Don't load from other websites. Put fonts and libraries in static/, so visitors aren't tracked and the site keeps working.
  • Keep pages small. Don't list every note on every page; big gardens have thousands. The built-in theme shows only the current folder or group.

The theme reference with every detail, including caching and the built-in theme's settings, is in docs/themes.md.