Skip to content

Layers, controls and pages

Three ways to add to a deck without touching its slides: a layer draws on every slide, the custom-nav-controls layer adds buttons beside the deck’s controls, and a routes file adds whole pages at addresses of their own. Each is a React component in a file beside deck.mdx, picked up by kadal-deck dev and kadal-deck build with nothing to register.

Put a component in one of these files, beside deck.mdx or in src/ (.tsx, .jsx, .ts or .js):

File Draws
global-top.tsx once, over the stage’s slides: a logo, a progress line, a watermark
slide-top.tsx with each slide, over its content
slide-bottom.tsx with each slide, under its content
global-bottom.tsx once, under the stage’s slides: a footer that doesn’t move

A footer with the page number, hidden on the cover:

global-bottom.tsx
import type { LayerProps } from "@kadal/deck";
export default function Footer({ page, total, layout }: LayerProps) {
if (layout === "cover") return null;
return (
<footer className="my-footer">
{page} / {total}
</footer>
);
}
src/styles.css
.my-footer {
position: absolute;
right: 48px;
bottom: 32px;
font-size: 24px;
color: var(--deck-muted);
}

Positions and sizes are in stage pixels: the stage is 1920 × 1080 and scales as a whole.

A layer gets LayerProps, as props or from useLayer() anywhere inside it:

Prop What
slide the slide it is drawn over: the current one for a global layer on the stage, its own for a slide layer
page, total the slide’s number counted from 1 among visible slides, and how many there are
index, h, v the same position counted from 0, and its column and row
click, clicks the click step being shown, and how many the slide has
layout, frontmatter the slide’s layout name and frontmatter, so a footer can hide on covers
view the page the deck is on: play, presenter, print…
print true on a still copy of the slide: a printed page, an exported picture, an overview tile, a thumbnail, the presenter’s next-slide box
layer which layer this is

On a printed page or a thumbnail a global layer is drawn once per page with that page’s slide, so read props.slide and props.page, not the navigation.

Layers draw in play, in print and in every export. In the presenter view they show unless the headmatter turns them off, and on overview tiles only if it turns them on:

layers:
presenter: false # default true
overview: true # default false

A layer that throws is dropped with a console message; the slide still draws.

<Deck layers> in src/main.tsx sets the same layers in code, and false hides a theme’s or an addon’s layer of that name:

<Deck manifest={manifest} config={config} layers={{ "global-bottom": Footer, "global-top": false }} />

Themes and addons ship layers in their module (layers: { "slide-bottom": Footer }). All of them draw, stacked from the bottom: the theme’s, then the addons’, then the deck’s own.

Buttons beside the controls: custom-nav-controls

Section titled “Buttons beside the controls: custom-nav-controls”

custom-nav-controls.tsx (beside deck.mdx or in src/) adds buttons of your own to the deck’s controls. In play they sit in a small toolbar at the stage’s bottom-left, faint until hovered or focused like the arrows; in the presenter view they join the end of its toolbar. They never appear on a printed page, a tile or a thumbnail.

custom-nav-controls.tsx
import { type LayerProps, useActions } from "@kadal/deck";
export default function NavControls({ view }: LayerProps) {
const actions = useActions();
return (
<button type="button" title="Overview" onClick={() => actions.toggleOverview()}>
▦{view === "presenter" ? " Overview" : null}
</button>
);
}

The component gets the same LayerProps, with view saying whether it is in play or the presenter view, and acts through useActions() (next, prev, first, last, toggleOverview…) or useNav(). It follows the controls, not layers:: it shows in play and in the presenter view whatever that setting says. A theme or an addon can add buttons the same way, under the layer name custom-nav-controls.

Besides the engine’s pages (/presenter, /notes, /overview…), a deck can add its own: a handout, a timer for the room, a page of links. A page is drawn inside the deck’s providers, so it reads the deck with useDeck() like anything else.

Put a routes file beside deck.mdx: routes.tsx (or src/routes.tsx, or setup/routes.ts):

routes.tsx
import { defineRoutes, type RoutePageProps, useDeck } from "@kadal/deck";
function Handout(_props: RoutePageProps) {
const deck = useDeck();
return (
<main className="handout">
<h1>{deck.config.title}</h1>
<ol>
{deck.manifest.slides.map((s) => (
<li key={s.index}>
<a href={`${deck.base}#/${s.h}/${s.v}`}>{s.title || `Slide ${s.index + 1}`}</a>
</li>
))}
</ol>
</main>
);
}
export default defineRoutes([
{ path: "handout", component: Handout, title: "Handout" },
{ path: "q/:id", load: () => import("./src/pages/Question.tsx"), title: "A question" },
]);
src/pages/Question.tsx
import type { RoutePageProps } from "@kadal/deck";
export default function Question({ params }: RoutePageProps) {
return <h1>Question {params.id}</h1>;
}

The handout opens at /handout and the question page at /q/7, under the deck’s address (https://decks.kadal.cc/@you/talk/handout once published).

Route key What
path under the deck’s base, no leading slash: handout, tools/timer; :name matches one segment and arrives in params; a trailing * matches the rest
component the page
load instead of component, a dynamic import, so the page is a chunk of its own loaded only when opened
title the browser tab’s title while the page is open

A page gets RoutePageProps: path (the path it was opened at), params and route. The engine’s own pages always win, so a route named presenter is never used.

defineRoutes also takes a function of the routes so far, defineRoutes((routes) => [...routes, …]), to change or remove what a theme added. <Deck routes={[…]}> in src/main.tsx does the same from code, a theme’s or addon’s module can export routes, and its setup(api) can call api.addRoute(route).

kadal-deck build copies index.html into a folder for every plain path the routes file spells out (dist/handout/index.html), so /handout works on GitHub Pages, S3 or nginx without rewrite rules. A path with a :param or a * opens through 404.html, which every build writes, on hosts that serve it for unknown paths. See Build and host anywhere.

Send feedback