Theming
ox-content provides a flexible Theme API that allows you to customize the appearance of your documentation site. You can use CSS variables for simple customization or write full JSX themes for complete control.
Quick Start
CSS Variable Customization
// vite.config.ts
import { defineConfig } from "vite";
import { oxContent, defineTheme, defaultTheme } from "@ox-content/vite-plugin";
export default defineConfig({
plugins: [
oxContent({
ssg: {
siteName: "My Docs",
theme: defineTheme({
extends: defaultTheme,
colors: {
primary: "#3498db",
},
socialLinks: {
github: "https://github.com/your/repo",
},
footer: {
message: "Released under the MIT License.",
copyright: "Copyright © 2024 My Company",
},
}),
},
}),
],
});
JSX Theme (Full Control)
ox-content supports JSX/TSX themes that render to static HTML with zero client-side JavaScript by default.
// theme/Layout.tsx
import { usePageProps, useSiteConfig, useNav, raw, each } from "@ox-content/vite-plugin";
export function Layout({ children }) {
const page = usePageProps();
const site = useSiteConfig();
const nav = useNav();
return (
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>
{page.title} - {site.name}
</title>
</head>
<body>
<nav>
{each(nav, (group) => (
<div>
<h3>{group.title}</h3>
<ul>
{each(group.items, (item) => (
<li>
<a href={item.href}>{item.title}</a>
</li>
))}
</ul>
</div>
))}
</nav>
<main>{children}</main>
</body>
</html>
);
}
Configure your tsconfig.json for JSX:
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@ox-content/vite-plugin"
}
}
CSS Variables Reference
Every theme color, layout dimension, and font stack is emitted as a
--octc--prefixed CSS custom property on :root. You can set them through the
theme config (below) or override them directly from custom CSS
— the variable is the single source of truth either way.
Colors
| Option | CSS Variable | Description |
|---|---|---|
colors.primary |
--octc-color-primary |
Primary accent color for links, active states |
colors.primaryHover |
--octc-color-primary-hover |
Primary color on hover |
colors.background |
--octc-color-bg |
Main background color |
colors.backgroundAlt |
--octc-color-bg-alt |
Alternative background (sidebar, code blocks) |
colors.text |
--octc-color-text |
Main text color |
colors.textMuted |
--octc-color-text-muted |
Muted/secondary text color |
colors.border |
--octc-color-border |
Border color |
colors.codeBackground |
--octc-color-code-bg |
Code block background |
colors.codeText |
--octc-color-code-text |
Code block text color |
Layout
| Option | CSS Variable | Description |
|---|---|---|
layout.sidebarWidth |
--octc-sidebar-width |
Sidebar width (default: 260px) |
layout.headerHeight |
--octc-header-height |
Header height (default: 60px) |
layout.maxContentWidth |
--octc-max-content-width |
Maximum content width (default: 960px) |
Fonts
| Option | CSS Variable | Description |
|---|---|---|
fonts.sans |
--octc-font-sans |
Sans-serif font stack |
fonts.mono |
--octc-font-mono |
Monospace font stack |
Only the keys you set are emitted. Omitted colors, fonts, and layout values fall back to the default theme, so overriding a single accent never forces you to redeclare the rest of the palette.
Dark Mode
colors defines the light palette and darkColors defines the dark one; Ox
Content emits both from a single build and switches between them with two
selectors:
[data-theme="dark"]— used when the page (or the reader, via the built-in header theme toggle) explicitly opts into dark mode. The toggle persists the choice inlocalStorageso it survives navigation.@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { … } }— honours the operating-system preference unless the reader has explicitly chosen light.
defineTheme({
extends: defaultTheme,
colors: { primary: "#3b82f6", background: "#ffffff" },
darkColors: { primary: "#60a5fa", background: "#060816" },
});
darkColors follows the same key-by-key fallback as colors: any key you leave
out inherits the default dark palette.
Entry Page Modes
The default theme supports two landing page modes:
default- the more branded, marketing-style entry pagesubtle- a quieter docs.rs-like presentation with tighter spacing and a more restrained hero
defineTheme({
extends: defaultTheme,
entryPage: {
mode: "subtle",
},
});
Page Props & Hooks
Access page data in your theme components using hooks:
usePageProps()
Returns the current page's data:
function PageHeader() {
const page = usePageProps();
return (
<header>
<h1>{page.title}</h1>
{page.description && <p>{page.description}</p>}
</header>
);
}
Available properties:
title- Page titledescription- Page descriptionhtml- Rendered HTML contenttoc- Table of contentspath- Source file pathurl- Output URLfrontmatter- Raw frontmatter objectlayout- Layout name
useSiteConfig()
Returns site-wide configuration:
function SiteHeader() {
const site = useSiteConfig();
return <header>{site.name}</header>;
}
useNav()
Returns navigation groups:
function Sidebar() {
const nav = useNav();
return (
<nav>
{each(nav, (group) => (
<section>
<h3>{group.title}</h3>
{each(group.items, (item) => (
<a href={item.href}>{item.title}</a>
))}
</section>
))}
</nav>
);
}
useIsActive(path)
Checks if a path is the current page:
function NavLink({ href, children }) {
const isActive = useIsActive(href);
return (
<a href={href} class={isActive ? "active" : ""}>
{children}
</a>
);
}
JSX Utilities
raw(html)
Renders raw HTML without escaping:
<div>{raw(page.html)}</div>
each(items, render)
Maps over arrays:
{
each(items, (item, index) => <li key={index}>{item.name}</li>);
}
when(condition, content)
Conditional rendering:
{
when(page.toc.length > 0, <aside class="toc">...</aside>);
}
Type Generation
ox-content auto-generates TypeScript types based on your pages' frontmatter. The generated types are saved to your output directory.
// Generated: page-props.d.ts
export interface PageFrontmatter {
title: string;
description?: string;
layout?: string;
// ... other fields from your frontmatter
}
export type PageProps = import("@ox-content/vite-plugin").PageProps<PageFrontmatter>;
Use the generated types:
import type { PageProps } from "./page-props";
function Layout() {
const page = usePageProps<PageProps["frontmatter"]>();
// page.frontmatter is now fully typed
}
Layout Switching
Support multiple layouts based on frontmatter:
// theme/index.tsx
import { createTheme } from "@ox-content/vite-plugin";
import { DefaultLayout } from "./layouts/Default";
import { EntryLayout } from "./layouts/Entry";
import { BlogLayout } from "./layouts/Blog";
export default createTheme({
layouts: {
default: DefaultLayout,
entry: EntryLayout,
blog: BlogLayout,
},
});
In your markdown:
---
layout: entry
title: Welcome
---
# Welcome to My Docs
Social Links
Add social links to the header. The shorthand form covers the common networks:
defineTheme({
extends: defaultTheme,
socialLinks: {
github: "https://github.com/your/repo",
twitter: "https://twitter.com/yourhandle",
discord: "https://discord.gg/yourserver",
},
});
For anything else, pass an array of { icon, link, label? } entries. The
icon field accepts several formats:
| Format | Example | Renders as |
|---|---|---|
Iconify prefix:name |
"mdi:mastodon" |
Iconify icon (any set), color-aware |
| Lucide | "lucide:rss" |
Lucide icon via Iconify |
| Image URL | "https://example.com/x.svg" |
<img> with that source |
| Local path | "/icons/x.svg" |
<img> resolved against the site base |
| Emoji / text | "📡" |
Rendered inline as-is |
defineTheme({
extends: defaultTheme,
socialLinks: [
{ icon: "mdi:mastodon", link: "https://mastodon.social/@you", label: "Mastodon" },
{ icon: "lucide:rss", link: "/feed.xml", label: "RSS" },
],
});
Inline SVG passed as an icon is sanitized — <script> is stripped — so an icon
string can never inject executable markup.
Embedded HTML (Slots)
The embed option injects raw HTML at fixed points in the page layout. All nine
positions are optional:
| Field | Renders… |
|---|---|
head |
inside <head> (analytics, preconnect, custom <meta>) |
headerBefore |
immediately before the header bar |
headerAfter |
immediately after the header bar |
sidebarBefore |
at the top of the sidebar, before the navigation |
sidebarAfter |
at the bottom of the sidebar, after the navigation |
contentBefore |
before the main content (above the article) |
contentAfter |
after the main content (below the article) |
footerBefore |
immediately before the footer |
footer |
replaces the default footer entirely |
defineTheme({
extends: defaultTheme,
embed: {
head: '<link rel="preconnect" href="https://fonts.googleapis.com">',
headerBefore: '<div class="announcement">New version!</div>',
contentAfter: '<div class="feedback">Was this helpful?</div>',
footer: '<footer class="custom">© My Project</footer>',
},
});
Embedded HTML is inserted verbatim, so only pass trusted markup.
Custom CSS and JavaScript
css is appended after the generated --octc-* variable overrides, so your
rules win on specificity ties and you can freely read or redefine the variables.
js is injected as an inline script on every page.
defineTheme({
extends: defaultTheme,
css: `
/* Override a generated variable for every page… */
:root {
--octc-max-content-width: 1100px;
}
/* …or target the rendered markup directly. */
.content h1 {
color: var(--octc-color-primary);
letter-spacing: -0.04em;
}
`,
js: `
console.log('Page loaded');
`,
});
For one-off tweaks you can also pass css straight to the ssg plugin option
without defining a full theme — it is merged the same way:
oxContent({
ssg: {
theme: { css: ".hero-name { letter-spacing: -0.04em; }" },
},
});
Default Theme Values
const defaultTheme = {
name: "default",
colors: {
primary: "#3b82f6",
primaryHover: "#2563eb",
background: "#ffffff",
backgroundAlt: "#f5f7fb",
text: "#131a30",
textMuted: "#4f607b",
border: "#d2dbea",
codeBackground: "#0b1328",
codeText: "#eaf2ff",
},
darkColors: {
primary: "#60a5fa",
primaryHover: "#93c5fd",
background: "#060816",
backgroundAlt: "#0d1528",
text: "#ebf2ff",
textMuted: "#8ea0bf",
border: "#223252",
codeBackground: "#0a1020",
codeText: "#e7f0ff",
},
fonts: {
sans: '"IBM Plex Sans", "Avenir Next", "Segoe UI Variable", "Segoe UI", sans-serif',
mono: '"IBM Plex Mono", "SFMono-Regular", Consolas, monospace',
},
layout: {
sidebarWidth: "260px",
headerHeight: "60px",
maxContentWidth: "960px",
},
socialLinks: {},
};
TypeScript Support
All types are exported:
import type {
ThemeConfig,
ThemeColors,
ThemeLayout,
ThemeFonts,
ThemeHeader,
ThemeFooter,
SocialLinks,
ThemeEmbed,
ResolvedThemeConfig,
PageProps,
BasePageProps,
SiteConfig,
NavGroup,
NavItem,
ThemeComponent,
ThemeProps,
} from "@ox-content/vite-plugin";