Theming

Restyle PzzaDocs from one config object, from global CSS tokens, or from the stable class names.

PzzaDocs never hardcodes a colour, size or font. Every value in the stylesheet comes from a --pd-* custom property, and the whole look is a thin layer of those tokens on top of a monochrome base. You can change that layer at three levels, from the least to the most involved:

  1. The theme section of docs.config.ts. Pick a preset, override colours, set the radius, fonts, layout widths, density and the code theme. Everything stays in one typed object and is emitted as CSS at render time.
  2. Token overrides in app/globals.css. Redefine any --pd-* property on :root and .dark. Useful when the values already live in your own design system.
  3. Class-based CSS. Every element the framework renders carries a stable pd-* class and state attributes, so you can restyle a single piece of chrome without touching the rest.

Most sites only need the first level. The other two are covered in CSS overrides.

Quick example#

docs.config.ts
import { defineConfig } from "pzzadocs";

export default defineConfig({
  title: "Acme Docs",
  theme: {
    preset: "warm",
    radius: 6,
    density: "compact",
    fonts: {
      sans: "var(--font-geist-sans), system-ui, sans-serif",
      mono: "var(--font-geist-mono), ui-monospace, monospace",
    },
  },
});

This switches the palette to the warm paper-and-ink preset, rounds every control to 6px, tightens the row heights and paddings, and points the two font tokens at variables published by next/font. Nothing else changes.

How it is applied#

RootProvider renders a server component called ThemeStyles. It reads the resolved theme config and emits a single <style> element containing a :root { ... } block for the light values and a .dark { ... } block for the dark values. The CSS is computed on the server during render, so:

  • there is no runtime JavaScript involved in applying the theme,
  • the first paint already uses your tokens, with no flash of the default look,
  • only the keys you actually set are emitted; an empty theme produces no <style> at all.

A preset expands to its full colour set first, then your explicit colors are layered on top. Numbers become pixel values (radius: 6 becomes 6px); strings are used as written. Setting radius also derives --pd-radius-sm as max(2px, calc(radius - 1px)) for inline code and keyboard keys.

If you build your own provider instead of using RootProvider, import ThemeStyles from pzzadocs/ui and render it once near the top of your tree with the resolved config: <ThemeStyles theme={config.theme} />. The signature and the underlying resolveThemeCss helper are documented in the Theme API reference.

Fonts#

Fonts are not bundled. Load them in your root layout with next/font and point the fonts tokens at the CSS variables it publishes:

app/layout.tsx
import { Geist, Geist_Mono } from "next/font/google";

const sans = Geist({ subsets: ["latin"], variable: "--font-geist-sans" });
const mono = Geist_Mono({ subsets: ["latin"], variable: "--font-geist-mono" });

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" className={`${sans.variable} ${mono.variable}`} suppressHydrationWarning>
      <body>{children}</body>
    </html>
  );
}
docs.config.ts
theme: {
  fonts: {
    sans: "var(--font-geist-sans), system-ui, sans-serif",
    mono: "var(--font-geist-mono), ui-monospace, monospace",
  },
}

fonts.sans sets --pd-font-sans (body and UI text) and fonts.mono sets --pd-font-mono (code). Both accept any font-family value. Keep the variable classes on <html> so portals such as the search dialog and the image zoom inherit them too.

Radius, density and layout#

docs.config.ts
theme: {
  radius: 8,
  density: "compact",
  layout: {
    sidebarWidth: 260,
    tocWidth: 200,
    contentWidth: "80ch",
    headerHeight: 56,
  },
}
  • radius sets --pd-radius for cards, code blocks, buttons, tabs and dialogs. Defaults to 4.
  • density switches between "comfortable" (the default) and "compact". Compact lowers the sidebar and TOC row height from 28px to 24px and reduces block gaps, table cell padding, box padding and card padding. The exact values are listed in the density tokens.
  • layout maps directly onto --pd-sidebar-width (240px), --pd-toc-width (220px), --pd-content-width (72ch) and --pd-header-height (48px). Numbers are pixels, strings are used verbatim, so "80ch" and "min(72ch, 90vw)" both work.

Code themes#

Code is highlighted with a pair of bundled themes, pzza-light and pzza-dark. They are deliberately subdued: the UI around the code is gray, so the token colours are the only hues on the page and stay low in saturation.

Adjusting the bundled themes#

The code section overrides individual token groups of the bundled pair. Set only what you want to change:

docs.config.ts
theme: {
  code: {
    light: { keyword: "#4338ca", comment: "#8a8a8a" },
    dark: { keyword: "#a5b4fc", string: "#86efac" },
  },
}

The available groups are plain, keyword, fn, string, number, type, tag, attribute, property, variable, comment, punctuation, regex, templatePunctuation and background. Their defaults are listed in the CodeTokenColors reference.

Using a different theme pair#

codeTheme replaces the bundled pair with any two shiki theme registrations. Import them from shiki/themes or pass your own objects:

docs.config.ts
import light from "shiki/themes/<name>.mjs";
import dark from "shiki/themes/<name>.mjs";

export default defineConfig({
  title: "Acme Docs",
  theme: {
    codeTheme: { light, dark },
  },
});

code is ignored with a custom pair

When codeTheme is a custom pair the code overrides are not applied, because they describe token groups of the bundled themes. Set codeTheme: "pzza" (the default) to use the bundled pair together with code.

The code block chrome still uses the regular tokens: --pd-surface-2 for the header, --pd-surface-3 for highlighted lines and --pd-border-strong for word highlights. The body background follows the theme's own background.

Next steps#