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:

  1. package.json — change the name, add the ./styles.css export
  2. src/index.ts — change the theme id and function name
  3. src/styles.css — change the visual design

The contract-first rule

Themes import from @karaoke-cms/contractsnever 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 (:root block 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-body flex, .region-left/.region-right width) — 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