1Add the script
Paste this line into your HTML file, just before </body>:
<script async src="https://uxd.zennia.sg/v1/deck-remote.js" data-adapter="auto"></script>
That's all a plain deck needs. async means your page loads and works normally even if the server can't be reached. The page can be hosted anywhere, or opened straight from your laptop (file://).
What data-adapter="auto" does
It checks how your page is built and picks the best way to drive it:
| Your page | What the phone gets |
|---|---|
Slides in the recommended structure: .slide sections inside #deck, plus a global show(n) | Full control: slide numbers and titles, Jump to slide, polls, and a Present button (P). |
| A reveal.js deck | Full control through reveal's own API, including fragments. |
| Any other HTML | Next / Prev press the arrow keys on the page. Scroll up / down move the page. The phone shows the page title instead of slide numbers, and polls aren't available. |
The next section shows how to build each kind of page so the remote gets the most out of it.
Optional attributes on the tag
| Attribute | Use it when |
|---|---|
data-next-key="PageDown"data-prev-key="PageUp" | Your page moves on keys other than the arrows. |
data-scroll="on" / "off" | You want to force the phone's Scroll up / down buttons on or off. |
data-present-key="p"data-present-class="presenting" | Your page has its own present mode: the key that toggles it, and the class it sets on <body>. The phone then shows a Present button instead of Full screen. |
data-adapter="reveal", "slides" or "keys" | You'd rather name the adapter than let auto detect it. |
data-id="my-talk" | Two copies of the same file should share a session. By default each file path is its own deck. |
2Structure your HTML
The remote works on any HTML, but how much it can do depends on what it can see. Pick the pattern that matches your page.
Slides: use this structure
Put each slide in a <section class="slide"> inside <div id="deck">, mark the showing slide with is-live, and move between slides with a global function called show(n). The remote recognises this pattern and gets full control: slide numbers and titles on the phone, Jump to slide, polls, and a Present button.
The template below is a complete deck in plain HTML, CSS and JavaScript. Start from it, or copy its structure into your own file. The parts marked needed are what the remote looks for. Style everything else however you like.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>My talk</title> <!-- the phone shows this -->
<style>
html, body { margin: 0; height: 100%; }
body { background: #ffffff; color: #111111; font: 24px/1.4 system-ui, sans-serif; }
/* needed: only the live slide shows */
.slide { display: none; box-sizing: border-box; height: 100vh; padding: 8vh 8vw; }
.slide.is-live { display: block; }
/* your own on-screen extras; hidden in present mode */
.chrome { position: fixed; right: 16px; bottom: 12px; font-size: 14px; opacity: .6; }
body.presenting .chrome { display: none; }
</style>
</head>
<body>
<!-- needed: #deck containing .slide sections -->
<div id="deck">
<section class="slide is-live">
<h1>My talk</h1> <!-- the first heading is the slide's title on the phone -->
<p>A subtitle</p>
</section>
<section class="slide">
<h2>Agenda</h2>
<ul><li>One</li><li>Two</li><li>Three</li></ul>
</section>
<section class="slide"
data-interaction='{"id":"q1","type":"poll","prompt":"Which is hardest?","options":["Hook","Practice","Feedback"]}'>
<h2>Quick poll</h2>
<div data-ix-results></div>
</section>
</div>
<div class="chrome" id="counter"></div>
<script>
const slides = Array.from(document.querySelectorAll('#deck .slide'));
let current = 0;
// needed: a global show(n), 0-based, that moves the is-live class
function show(n) {
current = Math.max(0, Math.min(slides.length - 1, n));
slides.forEach((s, i) => s.classList.toggle('is-live', i === current));
document.getElementById('counter').textContent = (current + 1) + ' / ' + slides.length;
history.replaceState(null, '', '#' + (current + 1));
}
// keys for the laptop; listen on document so no element needs focus
document.addEventListener('keydown', (e) => {
if (e.target.closest && e.target.closest('input, textarea, select, [contenteditable]')) return;
if (e.ctrlKey || e.metaKey || e.altKey) return;
if (['ArrowRight', 'PageDown', ' '].includes(e.key)) { e.preventDefault(); show(current + 1); }
else if (['ArrowLeft', 'PageUp'].includes(e.key)) { e.preventDefault(); show(current - 1); }
else if (e.key === 'Home') show(0);
else if (e.key === 'End') show(slides.length - 1);
else if (e.key.toLowerCase() === 'p') document.body.classList.toggle('presenting'); // the phone's Present button
});
show((parseInt(location.hash.slice(1), 10) || 1) - 1); // reopen on the slide in the URL
</script>
<!-- needed: the remote, last -->
<script async src="https://uxd.zennia.sg/v1/deck-remote.js" data-adapter="auto"></script>
</body>
</html>
- Make every slide a direct part of
#deckwith the classslide. Anything outside#deck, like a logo, a progress bar or the counter, isn't counted as a slide. - Start each slide with a heading (
h1–h3), or give itaria-label="…". That's the title in the phone's Jump to slide list. - Give the page or each slide a solid background colour. The phone tints itself to match, and the on-screen timer picks a contrasting panel.
- Transitions are fine. Animate however you like, as long as
is-liveends up on the showing slide. - Keep the P handler if your deck has on-screen extras to hide. It toggles
body.presenting, and the phone's Present button presses P and goes full screen. - Don't put the classes
readingorposterson<body>. In this structure they pause remote navigation, and they're reserved for a reading view.
A scrolling page
The one-line tag is enough. The phone's Scroll up / down move the page by about two-thirds of a screen, and holding a button creeps in small steps.
- Let one thing scroll. Either the page itself, or, if you have a fixed header or sidebar, a single main content area with
overflow-y: autothat fills the middle of the screen. Don't setoverflow: hiddenonhtmlorbodyunless a panel inside scrolls instead. - Next / Prev do nothing on their own on a plain page, because they press the arrow keys and a browser only scrolls on real key presses. To make them jump between sections, listen for the arrows:
<script>
// Next / Prev jump between the page's h2 headings
const sections = Array.from(document.querySelectorAll('h2'));
function jump(dir) {
// the section we're in: the last heading at or above the top of the screen
let here = -1;
sections.forEach((h, i) => { if (h.getBoundingClientRect().top <= 10) here = i; });
const target = sections[Math.max(0, Math.min(sections.length - 1, here + dir))];
if (target) target.scrollIntoView({ behavior: 'smooth' });
}
document.addEventListener('keydown', (e) => {
if (e.key === 'ArrowRight') jump(1);
if (e.key === 'ArrowLeft') jump(-1);
});
</script>
- Or give the phone a section picker: a
choicecontrol whose options are the headings, callingscrollIntoView(). See Add your own controls.
An interactive page
For prototypes and demos with tabs, steps, modals or states, the remote can drive anything your page can do through a function.
- Put each change behind a named function, like
openTab('pricing'),nextStep()orsetState('error'), then give the phone a control that calls it. Achoicesuits tabs and states, abuttonsuits one-off actions, and atogglesuits anything on or off. - If it has steps, make → and ← move between them, with a listener on
documentas in the slide template. Next / Prev then work straight away. If you can count the steps, give the remote adeckobject to get step numbers and Jump. - Forms and text fields are safe. The remote ignores its own shortcuts while someone is typing in a field on your page.
Checklist for any page
- A meaningful
<title>. The phone shows it. - A solid background colour on
<body>or on each slide. - Keyboard listeners on
documentorwindow, readinge.key. Don't attach them to an element that needs focus, and don't reject events wheree.isTrustedis false: the remote's key presses are simulated. - Q, A, I, M and ? left free for the remote.
- Only one thing scrolls: the page, or one main panel.
- Anything you want to trigger from the phone lives in a named function.
- The script tag goes in the page itself, last. If the page is embedded in another site in an
<iframe>, the tag goes in the embedded page, and you click into it once so it receives key presses.
3Start a session
- Open your page on the laptop (Chrome works best) and press Q. The page shows a QR code: "Scan to unlock this deck".
- Scan it with your phone and type the team password on the phone. The laptop never asks for it, because it's on the projector. You get three tries, and the code expires after a minute.
- Your phone is now the remote. The QR code disappears from the screen.
- Let the audience in (optional): press A to show the join QR code. People can also go to uxd.zennia.sg and type the six-character code.
Ask a teammate for the password. Don't write it into any HTML file, slide or shared document. Your laptop remembers it for 3 hours, so pressing Q again in that window goes straight to pairing. The phone keeps nothing, so any phone can unlock any session.
To end a session, tap the small red End at the top right of the phone. Everyone disconnects and the answers are deleted. A session also ends by itself 60 minutes after the page is closed.
Already on the phone, with no extra code
- Next / Previous, and Jump to slide
- Blank screen (the remote draws its own blackout)
- Scroll up / down on pages that scroll; hold a button to move in small steps
- A clock counting up from when you go full screen or into present mode
- Show / Hide join code for latecomers
- Present or Full screen
If you want one of these, you already have it. Don't build your own.
4Add your own controls
Custom controls let the phone change things on your page, like switching a theme, revealing an answer, showing a message or starting a countdown. You describe each control as data, and the phone works out how to draw it.
To add controls, replace the one-line tag from step 1 with the tag without data-adapter, followed by a short script:
<script async src="https://uxd.zennia.sg/v1/deck-remote.js"></script>
<script>
function startRemote() {
DeckRemote.init({
adapter: 'auto', // same detection as data-adapter="auto"
actions: {
// one entry per control on the phone
dark: {
kind: 'toggle',
label: 'Dark mode',
key: 'd', // optional keyboard shortcut on the laptop
icon: 'moon',
onChange: (on) => document.body.classList.toggle('dark', on),
},
answer: {
kind: 'button',
label: 'Reveal answer',
icon: 'eye',
run: () => document.getElementById('answer').hidden = false,
},
},
});
}
// The script loads async, so wait for it if it isn't here yet.
window.DeckRemote ? startRemote() : addEventListener('deckremote:ready', startRemote, { once: true });
</script>
How it fits together
- Each key in
actionsis the control's id, e.g.darkoranswer. Keep ids short and don't rename them later, because they key the value the page restores after a reload. - The behaviour lives in your page. A control calls a function, like
runoronChange, and that function does the work: toggles a class, sets a CSS variable, calls a function your page already has. - One
DeckRemote.initper page. Put every control in the sameactionsobject. - Keep the last line. The script tag is
async, soDeckRemotemay not exist yet when your script runs. The last line waits for it. - Controls work from the keyboard too. Give a control a
keyand it fires on the laptop as well, even with no phone paired. - Values survive a reload. Toggles, choices, numbers and the timer come back when the page reloads mid-session.
Control kinds
Set kind to one of these eight. Each one gives your page a different callback.
kind | On the phone | Your page supplies | Good for |
|---|---|---|---|
button | a tile you tap | run() | one-off actions: reveal, jump, reset, play an animation |
momentary | acts only while held | onPress(), onRelease() | spotlight, hold-to-reveal |
toggle | on / off, lit when on | onChange(on) | theme, notes, an overlay |
choice | a row of options | options, onChange(value) | a mode, a section, a variant |
number | value, − / + and a slider | min, max, step, onChange(n) | text size, zoom, speed |
text | a field and Send | onChange(text) | a question or message on screen |
timer | its own card: start, pause, reset, ± 30 s, presets | seconds; optional onTick(ms), onDone() | exercises and breaks (one per page) |
readout | a value, no input | value: () => 'text' | something you want to see but not change |
Fields every kind accepts
| Field | Meaning |
|---|---|
label | What the phone shows. Keep it under about 20 characters, and name the thing, not its state: "Speaker notes", not "Notes on/off". |
key | A keyboard shortcut on the laptop, e.g. 'n' or 'shift+n'. |
group | A folding section on the phone, e.g. 'Look'. Use groups once you have more than a handful of controls. |
confirm: true | The phone asks before firing. Use it for anything you'd regret tapping mid-talk. |
icon | The tile's icon: x, one, eye, star, shuffle, note, moon, blank, timer, play, reset, jump, present. |
value | The starting value of a toggle, choice or number. |
Extra fields for some kinds
choice:optionsis a list of strings, or of{ value, label }objects. Addcolor: '#4f7cff'to an option and the phone shows a colour swatch instead of the word.compact: truelets it share a row with other tiles.number:min,max,step,unit(e.g.'%').text:placeholder,maxLength(up to 200). The phone adds its own × to clear the text.timer:seconds,display: 'large',resetKey. The countdown appears on screen only once started, turns amber under 30 s and red under 10 s, and can be dragged and resized.
A complete example
Save this as an .html file, open it on your laptop, press Q and pair your phone. Each control changes something you can see.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Remote test</title>
<style>
body { font: 20px/1.5 system-ui, sans-serif; margin: 10vh auto; max-width: 40em; padding: 0 1em;
background: #fff; color: #111; transition: background .3s, color .3s; }
body.dark { background: #111; color: #eee; }
#message { font-size: 2em; font-weight: 700; color: var(--accent, #2f5bea); min-height: 1.5em; }
#answer { padding: 1em; border-radius: 12px; background: #fef3c7; color: #111; }
</style>
</head>
<body>
<h1>What is the capital of Australia?</h1>
<p id="message"></p>
<p id="answer" hidden>Canberra</p>
<script async src="https://uxd.zennia.sg/v1/deck-remote.js"></script>
<script>
function startRemote() {
DeckRemote.init({
adapter: 'auto',
actions: {
answer: { kind: 'toggle', label: 'Show answer', key: 'v', icon: 'eye',
onChange: (on) => { document.getElementById('answer').hidden = !on; } },
dark: { kind: 'toggle', label: 'Dark mode', key: 'd', icon: 'moon', group: 'Look',
onChange: (on) => document.body.classList.toggle('dark', on) },
accent: { kind: 'choice', label: 'Accent', group: 'Look', compact: true, value: 'blue',
options: [{ value: 'blue', label: 'Blue', color: '#2f5bea' },
{ value: 'green', label: 'Green', color: '#16a34a' },
{ value: 'pink', label: 'Pink', color: '#db2777' }],
onChange: (v) => document.body.style.setProperty('--accent',
{ blue: '#2f5bea', green: '#16a34a', pink: '#db2777' }[v]) },
size: { kind: 'number', label: 'Text size', group: 'Look',
min: 80, max: 160, step: 10, value: 100, unit: '%',
onChange: (n) => { document.documentElement.style.fontSize = n + '%'; } },
message: { kind: 'text', label: 'Message on screen', placeholder: 'Type a message', maxLength: 80,
onChange: (s) => { document.getElementById('message').textContent = s; } },
clock: { kind: 'timer', label: 'Thinking time', seconds: 60, display: 'large', key: 't' },
},
});
}
window.DeckRemote ? startRemote() : addEventListener('deckremote:ready', startRemote, { once: true });
</script>
</body>
</html>
For a page using all eight kinds, see the control playground.
Pages with their own slide code
If your page already moves between slides or steps with its own code, and doesn't follow the recommended structure, give the remote a deck object instead of adapter. You then get slide numbers, titles, Jump to slide and polls.
DeckRemote.init({
deck: {
next: () => myDeck.next(),
prev: () => myDeck.prev(),
goTo: (i) => myDeck.goTo(i), // 0-based
current: () => myDeck.index, // 0-based
total: () => myDeck.length,
title: (i) => myDeck.titleOf(i),
el: (i) => myDeck.slideElement(i), // optional: where polls are declared
},
actions: { /* your controls, as above */ },
});
All six functions from next to title are required. If one is missing, the remote switches itself off and logs the reason in the browser console. Your page keeps working.
Audience polls
Polls live on slides, so they need a deck the remote can count: the recommended structure, reveal.js, or your own deck object. Add a data-interaction attribute to the slide:
<section data-interaction='{"id":"q1","type":"poll","prompt":"Which is hardest?","options":["Hook","Practice","Feedback"],"audienceResults":"none"}'>
<h2>Which is hardest?</h2>
<div data-ix-results></div> <!-- where the bars appear (optional) -->
</section>
On that slide, press I (or use the phone) to open the poll, I again to close it, and once more to reveal the results. Use a different id for each poll.
audienceResults | Audience phones see the results |
|---|---|
"none" (default) | never; results are on the main screen only |
"reveal" | when you reveal, at the same moment as the screen |
"live" | as votes come in |
Polls are anonymous: no sign-in, no names, and all answers are deleted when the session ends.
Keyboard shortcuts
These keys belong to the remote. Don't give your own controls these keys.
| Q | Unlock, or pair a phone as the remote |
| Shift + Q | Disconnect the current phone and pair again |
| A | Show the audience join code |
| I | Poll on this slide: open → close → reveal |
| M | Poll on this slide: switch who sees results (before opening) |
| ? | List every shortcut on this page |
| Esc | Close the overlay |
Also avoid the keys your page already uses. The recommended slide template uses the arrows, Space, Page Up / Page Down, Home / End and P. If two things share a key, the remote wins and your page never sees the key press, so test every shortcut you assign.
Limits and troubleshooting
Limits
- Up to 30 controls per page, 20 options per
choice, and one timer. - Labels up to 40 characters;
textandreadoutvalues up to 200. Longer values are cut rather than rejected. - A
readoutrefreshes when the page's state changes, such as a slide change or a control being used. It doesn't tick on its own. - An unknown
kindis drawn as a plain button. - Up to 500 audience members per session.
If something doesn't work
- Nothing happens when I press Q. Open the browser console (F12) and look for a line starting with
[deck-remote]. It explains what's wrong, e.g. a missingdeckfunction. Also check that focus isn't in a text field. - A control doesn't appear on the phone. Check that
DeckRemote.initruns only once and that every control has akindand alabel. If the phone was paired before you edited the page, reload the page. - The phone can't connect. Some school and office Wi-Fi blocks live connections. Try mobile data on the phone. On the laptop, switch off VPN or iCloud Private Relay so the session is created near you.
- The password is refused. After three wrong tries the QR code stops working. Press Q for a new one.
Keep it private
The page is on a projector, and text and readout values travel to the phone. Don't put anything confidential in them, and never put the team password in a file.