Writing a Custom Theme
A theme controls the visual identity of your site: colors, typography, layout, and dark mode. Modules declare the CSS class names they use; the theme provides the styles for those classes. Switching themes changes the look without touching your content.
Quick start: copy theme-default
The fastest way to create a theme is to copy an existing one:
cp -r packages/theme-default packages/theme-mine
Then update three files:
package.json— change the name, add the./styles.cssexportsrc/index.ts— change the theme id and function namesrc/styles.css— change the visual design
The contract-first rule
Themes import from @karaoke-cms/contracts — never from @karaoke-cms/astro. The contracts package has zero runtime dependencies beyond a peer reference to astro.
// package.json
{
"peerDependencies": {
"astro": ">=6.0.0",
"@karaoke-cms/contracts": "^0.18.0"
}
}
Package structure
packages/theme-mine/
src/
index.ts <- defineTheme() + export
styles.css <- visual identity: tokens, layout, module styles
layout.config.ts <- optional per-variant region layouts
pages/
index.astro <- homepage (optional)
404.astro <- not found page (optional)
package.json
package.json
The ./styles.css export is required so apps can import it in their imported.css:
{
"name": "@my-org/theme-mine",
"type": "module",
"main": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./styles.css": "./src/styles.css"
},
"peerDependencies": {
"astro": ">=6.0.0",
"@karaoke-cms/contracts": "^0.18.0"
}
}
src/index.ts
defineTheme() takes a ThemeDefinition and returns a factory function. Calling the factory produces a ThemeInstance.
import type { AstroIntegration } from 'astro';
import type { ModuleInstance, ThemeInstance } from '@karaoke-cms/contracts';
import { fileURLToPath } from 'url';
import { existsSync } from 'fs';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
function buildIntegration(activeModules: ModuleInstance[] = []): AstroIntegration {
const hasBlog = activeModules.some(m => m.id === 'blog');
const hasTags = activeModules.some(m => m.id === 'tags');
return {
name: '@my-org/theme-mine',
hooks: {
'astro:config:setup': ({ injectRoute, updateConfig, config: astroConfig }) => {
// Inject a homepage if the user doesn't have one
const userIndex = fileURLToPath(new URL('src/pages/index.astro', astroConfig.root));
if (!existsSync(userIndex)) {
injectRoute({ pattern: '/', entrypoint: `${__dirname}pages/index.astro` });
}
// Inject a 404 page
injectRoute({ pattern: '/404', entrypoint: `${__dirname}pages/404.astro` });
// Set up the @theme alias (lets layout components reference theme files)
updateConfig({
vite: {
resolve: {
alias: { '@theme': `${__dirname}` },
},
},
});
},
},
};
}
export function themeMine(): ThemeInstance {
return {
_type: 'theme-instance',
id: 'theme-mine',
toAstroIntegration: (activeModules: ModuleInstance[] = []) =>
buildIntegration(activeModules),
};
}
src/styles.css
Your theme’s stylesheet provides all the visual styling. It’s imported by the app’s imported.css after theme-base (structural layout) and module default CSS, so your rules override both.
What to include:
- CSS custom properties (
:rootblock with colors, fonts, spacing, radii) - Dark mode (
@media (prefers-color-scheme: dark)) - Header, footer, sidebar visual styling
- Module contract classes (
.blog-*,.docs-*,.tag-*, etc.) - karaoke-menu visual styling (dropdown backgrounds, hover colors)
What NOT to include:
- Structural layout (
.page-bodyflex,.region-left/.region-rightwidth) — theme-base handles this - A JavaScript dark mode toggle — dark mode is CSS-only
:root {
--font-body: system-ui, sans-serif;
--color-bg: #ffffff;
--color-text: #111111;
--color-link: #0066cc;
--color-border: #e5e5e5;
--color-muted: #737373;
--width-site: 800px;
--width-content: 680px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #0f0f0f;
--color-text: #e5e5e5;
--color-link: #60a5fa;
--color-border: #262626;
--color-muted: #9ca3af;
}
}
body {
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-body);
}
/* Module contract classes — implement at least the required: true ones */
.blog-list { /* ... */ }
.blog-card { /* ... */ }
.blog-post { /* ... */ }
.docs-tree { /* ... */ }
.docs-sidebar { /* ... */ }
The CSS cascade
Apps compose CSS with an explicit import chain in src/styles/imported.css:
@import '@karaoke-cms/theme-base/styles-astro.css'; /* 1. structural layout */
@import '@karaoke-cms/module-blog/blog-styles.css'; /* 2. module defaults */
@import '@karaoke-cms/module-docs/docs-styles.css';
@import '@karaoke-cms/module-tags/tags-styles.css';
@import '@karaoke-cms/module-search/search-styles.css';
@import '@karaoke-cms/module-comments/comments-styles.css';
@import '@my-org/theme-mine/styles.css'; /* 3. theme (overrides all above) */
Later imports win in the cascade. Your theme CSS overrides module defaults and theme-base structural CSS.
Using the theme
// karaoke.config.ts
import { defineConfig } from '@karaoke-cms/astro';
import { themeMine } from '@my-org/theme-mine';
export default defineConfig({
theme: themeMine(),
modules: [...],
});
Update the app’s src/styles/imported.css to import your theme instead of theme-default.
Modifying an existing theme
To tweak a built-in theme without forking it, use the app’s styles.css:
/* src/styles/styles.css */
@import './imported.css';
/* Override theme-default's accent color */
:root {
--color-accent: #e11d48;
}
/* Override blog card styling */
.blog-card {
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
App-level CSS comes last in the cascade and overrides everything.
Extending ThemeFactoryConfig
Themes can accept user configuration:
import type { ThemeFactoryConfig } from '@karaoke-cms/contracts';
interface MineConfig extends ThemeFactoryConfig {
accentColor?: string;
}
export const themeMine = defineTheme({
id: 'theme-mine',
toAstroIntegration: (config: MineConfig, modules) => {
// Use config.accentColor to customize CSS injection
return buildIntegration(modules, config.accentColor ?? '#0066cc');
},
});
Variant layouts
Themes can declare per-variant region layouts (which components go in main/left/right for different page types). Create a layout.config.ts:
import { defineLayoutConfig } from '@karaoke-cms/contracts';
export default defineLayoutConfig({
'karaoke.blog.post': {
main: [{ component: 'body', weight: 20 }],
right: [{ component: 'karaoke.blog.recent-list', weight: 20 }],
},
});
Import and pass it to the ThemeInstance:
import layoutOverrides from './layout.config.ts';
export function themeMine(): ThemeInstance {
return {
_type: 'theme-instance',
id: 'theme-mine',
layoutOverrides,
toAstroIntegration: (activeModules) => buildIntegration(activeModules),
};
}
Validating your theme
Run the /theme-contract skill to check that your theme implements all required: true CSS classes for every module:
/theme-contract