Presenting
Keyboard and swipe navigation, the command center, the presenter window, speaker notes, step reveals, and the deck controls cluster.
Keyboard
| Keys | What happens |
|---|---|
→ ↓ PageDown Space |
Next step, then next slide |
← ↑ PageUp |
Previous step, then previous slide |
Cmd/Ctrl + K |
Toggle the command center |
P |
Open the presenter window |
D |
Switch the canvas between light and dark |
Navigation keys are ignored while you are typing in a field, when the event already had preventDefault called on it, and when a modifier is held, so browser zoom (Cmd +, Cmd -) still zooms.
On a stepped slide the arrow keys walk the steps first and only then move to the next slide, so one key drives the whole deck.
Touch
On a touchscreen, swipe left for the next step or slide and swipe right for the previous one. A swipe walks the steps the same way the arrow keys do.
A swipe counts when it is mostly horizontal, at least 50 browser pixels long, and finished within 800 milliseconds. It is ignored when it starts:
- on a link, button, input, or anything marked
data-step-ignore-click="true" - inside content that scrolls sideways, such as a wide code block
- on the deck controls or an open dialog
- near the left or right edge of the screen, where the phone runs its own back gesture
- while the page is pinch-zoomed, or once a second finger lands
The thresholds live in packages/core/src/components/slide-stepper.tsx. The deck page also turns off sideways overscroll on the root element, so Chrome on a touchscreen does not step browser history on the same swipe.
The command center
Cmd/Ctrl + K opens a searchable list of every slide by number and title. Pick one and the deck navigates. It opens whether or not the controls cluster is showing.
The deck controls
The command center trigger, presenter popout, color mode toggle, and compact previous and next buttons sit in one cluster in the bottom right corner, outside the canvas.
The cluster shows for a couple of seconds when the deck first loads, then hides. Moving the pointer anywhere in the window brings it back, and it hides again once the pointer has been still for the same couple of seconds, unless the pointer rests near the corner, something inside the cluster has focus, the command dialog is open, or it has been pinned open. The first-load reveal happens once per page load, not on every slide, and never while a deckard capture command is photographing the page or inside a presenter preview.
A phone or a tablet, where (pointer: coarse) matches, docks the cluster in a row directly under the slide and keeps it showing. The slide shrinks to leave the row room, so the controls never cover the footer.
Any other device matching (any-pointer: coarse) gets a handle in the corner that pins the cluster open on tap, and touch movement leaves the cluster alone. The pointer reveal is on wherever (any-pointer: fine) or (hover: hover) matches. These queries ask per capability rather than about the primary pointer, so a hybrid laptop with a trackpad and a touchscreen gets both affordances.
SlideShell takes a controlsAccessory, a deck’s own control rendered first in the cluster. It reveals and docks with the rest of the cluster.
Hidden means transparent, not absent. The buttons stay in the accessibility tree and tabbing into them reveals the cluster. A presenter preview and a PDF export drop the cluster entirely and keep the header and footer.
The presenter window
Press P or click the popout button. A second window opens at /presenter and syncs with the deck over a BroadcastChannel, so the two stay in step without a server.

What it shows:
- The current slide’s position and title, and the slide itself
- A preview of what comes next, aware of reveal steps, so the preview advances with them
- Speaker notes for the current slide, with font-size controls
- A timer that starts when the deck connects, with buttons to pause, resume, and reset it, and a 24-hour clock. Reset zeroes the timer and leaves it running or paused as it was.
- A flow window: the current slide with a little of the deck behind it and more of it ahead, by title. Click any of them to jump the deck there.
The navigation keys in either window drive both. In the presenter window they work wherever focus is, except in a text field, and Space on a focused button presses the button.
Notes
notes is speaker-only text on a slide. It never renders in the deck.
{
title: "Pricing",
notes: "They will ask about the enterprise tier. The answer is 'call us'.",
body: <PricingSlide />,
}
In a slide module, export it:
export const notes = "They will ask about the enterprise tier."
Notes written the way a speaker writes them run three or four paragraphs. That is the usual reason a slide earns its own file.
Detecting the preview
The presenter preview renders slide routes with ?presenterPreview=1. A client component inside a slide can detect it:
"use client"
import { useIsPresenterPreview } from "@thebuilder/deckard-core/components"
export function Chart() {
const isPreview = useIsPresenterPreview()
return isPreview ? <ChartStill /> : <ChartAnimated />
}
Use it to skip autoplay, audio, canvas work, or anything else expensive inside the preview iframe. The built-in video primitive already suppresses autoplay there, and a theme’s motion background mounts no canvas there, so the preview shows the background the theme paints underneath it.
The preview frames also turn autoplay off for themselves and every frame nested in them, so in browsers that support the autoplay permission policy, audio a slide starts on its own, including Web Audio in an embedded page, stays silent in the presenter window. Checking useIsPresenterPreview() before starting the sound covers every browser and saves the work.
The slide route itself never reads the query. ?presenterPreview=1 and ?step= are read on the client inside a Suspense boundary that holds nothing but the reader, which is what lets every slide prerender as static HTML.
Step reveals
Set stepCount on the slide and wrap each phase in SlideStep:
{
title: "The argument",
stepCount: 3,
body: (
<OpenContentSlide eyebrow="Three parts" title="The argument">
<SlideStep step={0}>Everyone agrees on the premise.</SlideStep>
<SlideStep step={1}>Almost nobody acts on it.</SlideStep>
<SlideStep step={2}>Here is what that costs.</SlideStep>
</OpenContentSlide>
),
}
step is zero-based. A step is visible once the current step reaches it, so step 0 shows immediately.
| Prop | Default | What it does |
|---|---|---|
step |
required | The zero-based index this content appears at. |
mountOnReveal |
false |
Render an empty placeholder until the step arrives, instead of a hidden copy. Use it for something expensive. |
className |
Applied to the step wrapper. |
SlideStepAdvanceArea wraps content in a click-to-advance surface. A click moves on the way the right arrow does, through the slide’s steps and then to the next slide. Clicks on links, buttons, inputs, and anything marked data-step-ignore-click="true" pass through untouched, and a swipe that starts on one of them does not page the deck.
useSlideStepper() returns the current step, the count, and advance / retreat / next / canAdvance / canRetreat / isReadOnly, for a slide that wants to drive its own reveal. It returns null outside a stepper.
Color mode
A theme declares which color modes it carries and what a browser opens on. defaultColorMode: "system" needs both modes listed. A theme that lists one pins the canvas to it and hides the light/dark toggle in the controls. There is no runtime switching between named themes.
defaultColorMode decides what a browser opens on, not what the checks capture. --light and the light PDF export force the mode they name, and a capture that came out in the other mode fails naming the slide rather than being written.