Skip to content

Writing a theme

A theme is a look: CSS scoped under .theme-<name> that sets the deck’s tokens and styles its layouts, plus, if it wants, layouts, components and layers of its own. An addon is a feature any theme can use. Both are npm packages (or plain folders) of the same shape. To change an existing theme for one deck, eject it instead.

Terminal window
npx kadal-deck new --theme aurora
cd kadal-theme-aurora
npm install
npm run dev # the preview deck, live
Terminal window
pnpm exec kadal-deck new --theme aurora
cd kadal-theme-aurora
pnpm install
pnpm dev # the preview deck, live
Terminal window
yarn kadal-deck new --theme aurora
cd kadal-theme-aurora
yarn install
yarn dev # the preview deck, live
Terminal window
bunx kadal-deck new --theme aurora
cd kadal-theme-aurora
bun install
bun run dev # the preview deck, live

(npm create @kadal/deck -- --theme aurorapnpm create @kadal/deck -- --theme aurorayarn create @kadal/deck -- --theme aurorabun create @kadal/deck -- --theme aurora does the same without the CLI installed.) You get a package that already follows every rule on this page:

File What it is
package.json the name kadal-theme-aurora, the kadal-theme keyword and the theme’s metadata
style.css every token in light and dark, every layout class styled
layouts.tsx a layout of its own, hero
index.tsx exports the layouts, and a footer layer that themeConfig.footer: false hides
preview/ a deck that uses the theme from its folder (theme: ../): npm run devpnpm devyarn devbun run dev, npm run checkpnpm checkyarn checkbun run check and npm run buildpnpm buildyarn buildbun run build run it
README.md install, themeConfig and layouts, ready to fill in
tsconfig.json types for the module and the preview

package.json carries what the engine needs to know, under kadal.theme:

{
"name": "kadal-theme-aurora",
"keywords": ["kadal-theme"],
"type": "module",
"exports": {
".": "./index.tsx",
"./style.css": "./style.css",
"./package.json": "./package.json"
},
"kadal": {
"theme": {
"name": "aurora",
"title": "Aurora",
"colorScheme": "both",
"themeConfig": { "primary": "#0f766e", "footer": true },
"config": {
"transition": "fade",
"fonts": { "sans": "Inter", "heading": "Fraunces", "mono": "JetBrains Mono" }
},
"defaults": {},
"shiki": { "light": "github-light", "dark": "github-dark" },
"layouts": ["hero"],
"components": []
}
},
"peerDependencies": { "@kadal/deck": "^0.4.2", "react": "^19.2.0" }
}
  • name is the CSS class (.theme-aurora) and what decks write as theme:.
  • colorScheme: both, light or dark. A one-scheme theme makes that scheme the deck’s default.
  • config is headmatter the theme brings (a deck’s own headmatter wins); defaults is frontmatter for every slide (a deck’s defaults and a slide’s own keys win).
  • themeConfig holds the defaults of the theme’s knobs (below); shiki colours the code.
  • layouts and components name what the module adds, for kadal-deck check, info themes and AI agents.

./style.css is the only required export. TSX source is fine for .: decks compile it.

Scope everything under .theme-<name> (other themes sit on the same page) and set every token, in light and in dark:

.theme-aurora {
--deck-bg: #fbfaf7;
--deck-fg: #1c1917;
--deck-muted: #57534e;
--deck-accent: var(--theme-primary, #0f766e);
/* … every token below … */
}
.theme-aurora[data-color-scheme="dark"] {
--deck-bg: #0c0a09;
--deck-fg: #f5f5f4;
/* … */
}

Write the dark block exactly as .theme-<name>[data-color-scheme="dark"], never .dark .theme-… or a media query: a slide that forces its own scheme (colorScheme: dark) carries the attribute too.

Token For
--deck-bg, --deck-fg the slide’s background and text
--deck-muted subtitles, captions, the slide number
--deck-accent, --deck-accent-2 links, highlights, the progress bar; a second accent
--deck-surface, --deck-border cards, callouts and inline code; hairlines
--deck-heading-font, --deck-body-font, --deck-mono-font, --deck-serif-font the faces
--deck-code-bg, --deck-code-fg, --deck-code-active code blocks and the highlighted line
--deck-radius, --deck-pad, --deck-base-size corners, the slide’s padding, the base font size (slides are 1920 × 1080)
--deck-live, --deck-transition, --deck-controls-color the live room’s accent, the transition’s length, the arrows

Then style the layouts: .deck-layout-default, -center, -cover, -intro, -section, -end, -fact, -quote, -statement, -full, -two-cols, -two-cols-header, -image, -image-left, -image-right, -iframe, -iframe-left, -iframe-right. Every slide is section.deck-slide.layout-<name> with the layout’s .deck-layout inside, so content is .theme-aurora .deck-slide h1, … blockquote, … table. Slots are .deck-slot.deck-slot-<name>. Classes themes often touch: .deck-progress span, .deck-slide-number, .deck-callout, .deck-badge, .deck-code-block, .deck-code-title, .deck-kbd, .deck-toc. Prefix your own classes with the theme’s name.

Decoration. Shapes that reach past the slide’s padding (a ground, a wave along the foot, a corner blob) go on the slide (.theme-aurora .deck-slide.layout-cover::before) or on an element with the deck-absolute class, never off .deck-layout. Anything that sticks out of .deck-layout counts as overflow, and the engine’s auto-fit shrinks the whole slide to make room for it.

Each themeConfig key, the theme’s defaults with the deck’s values over them, arrives on the deck root as --theme-<kebab-key>: primary → --theme-primary, primaryColor → --theme-primary-color, cover: { image } → --theme-cover-image. Lists become comma lists, booleans 1 or 0. Always read them with a fallback:

.theme-aurora { --deck-accent: var(--theme-primary, #0f766e); }

A layout or a layer reads the raw values from useDeck().config.themeConfig. Document every key in the README.

Name the families in the tokens, and load them through the metadata’s config.fonts ({ sans, heading, serif, mono, weights, italic, provider, local, fallbacks }) rather than an @import. Exports wait for fonts loaded this way, and a theme picked from the theme menu brings them along. A deck’s own fonts: wins over the theme’s.

The module is optional. Its default export (named exports work too) adds to the deck:

index.tsx
import type { LayerProps, LayoutProps, SetupApi } from "@kadal/deck";
import { useDeck } from "@kadal/deck";
function Hero({ children }: LayoutProps) {
return <div className="deck-layout deck-layout-hero">{children}</div>;
}
function Footer({ page, total, layout }: LayerProps) {
const deck = useDeck();
if (layout === "cover" || deck.config.themeConfig.footer === false) return null;
return (
<footer className="aurora-footer">
{deck.config.title} · {page} / {total}
</footer>
);
}
export default {
layouts: { hero: Hero }, // layout: hero; a built-in name replaces that layout
components: {}, // usable in any slide by name
layers: { "slide-bottom": Footer }, // also global-top, global-bottom, slide-top, custom-nav-controls
routes: [], // pages of the theme's own, e.g. { path: "handout", component: Handout }
setup(api: SetupApi) {
// optional; may return a cleanup
api.shortcut("shift+k", () => api.deck.actions.toast("hi"));
},
};

A layout wraps its children in .deck-layout plus a class of its own, and receives frontmatter and options. A layer gets the slide, its page number, the click and print (true on a printed page or a thumbnail), so read props.slide, not the navigation. routes adds pages under the deck’s address, as a deck’s own routes.tsx does (see Layers, controls and pages). setup(api) can register commands, presenter sidebar panels, backgrounds, island renderers, shortcuts and pages (api.addRoute).

The deck’s own components and layouts win over an addon’s, which win over the theme’s. Layers stack: the theme’s lowest, then the addons’, then the deck’s.

  • npm run devpnpm devyarn devbun run dev runs the preview deck; npm run checkpnpm checkyarn checkbun run check lints it; npm run buildpnpm buildyarn buildbun run build builds it.
  • Look at it in light and dark, every layout (two-cols-header too), the presenter view, print (?print-pdf) and a phone-width scroll view (?view=scroll).
  • Check the contrast of --deck-muted on --deck-bg, and keep anything that moves still under prefers-reduced-motion.
  • From a deck: npx kadal-deck info themespnpm exec kadal-deck info themesyarn kadal-deck info themesbunx kadal-deck info themes should list it with its layouts.

Contributing a theme to the Kadal Deck repository (packages/theme-<name>)? Two more tools run there: bun tools/theme-lint.ts packages/theme-<name> checks the contract statically (each theme’s bun test runs it), and node tools/theme-shots.mjs <name> runs the preview deck in Chromium: a PNG of each slide in light and dark, the WCAG contrast of the resolved tokens, anything that overflows or that auto-fit had to shrink, the presenter, print and phone views, and contact sheets. A first-party theme needs no build step and no edit anywhere else: the hosted player finds every packages/theme-* on its own.

Terminal window
npm publish

Keep the kadal-theme keyword: it is how people, and kadal-deck info themes, find it. A deck then uses it with npm i kadal-theme-aurorapnpm add kadal-theme-aurorayarn add kadal-theme-aurorabun add kadal-theme-aurora and theme: aurora.

npx kadal-deck new --addon <name>pnpm exec kadal-deck new --addon <name>yarn kadal-deck new --addon <name>bunx kadal-deck new --addon <name> scaffolds an addon the same way. Its metadata sits under kadal.addon (name, title, description, defaults, layouts, components), its keyword is kadal-addon, and its module has the same layouts, components, layers, routes and setup. A deck lists it with addons: [name], which finds @kadal/addon-<name>, then kadal-addon-<name>, then a package of that name or a folder.

Send feedback