Deck Remote · uxd.zennia.sg

How to use the deck remote

Control any HTML page from your phone, and run live polls with the room. It works with slides, long scrolling pages and interactive prototypes, however they were made. Adding one script tag gets you a working remote. A little structure and a few lines of JavaScript get you the rest.

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 pageWhat 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 deckFull control through reveal's own API, including fragments.
Any other HTMLNext / 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

AttributeUse 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 #deck with the class slide. Anything outside #deck, like a logo, a progress bar or the counter, isn't counted as a slide.
  • Start each slide with a heading (h1h3), or give it aria-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-live ends 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 reading or posters on <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: auto that fills the middle of the screen. Don't set overflow: hidden on html or body unless 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>

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() or setState('error'), then give the phone a control that calls it. A choice suits tabs and states, a button suits one-off actions, and a toggle suits anything on or off.
  • If it has steps, make and move between them, with a listener on document as in the slide template. Next / Prev then work straight away. If you can count the steps, give the remote a deck object 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 document or window, reading e.key. Don't attach them to an element that needs focus, and don't reject events where e.isTrusted is 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

  1. Open your page on the laptop (Chrome works best) and press Q. The page shows a QR code: "Scan to unlock this deck".
  2. 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.
  3. Your phone is now the remote. The QR code disappears from the screen.
  4. 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 actions is the control's id, e.g. dark or answer. 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 run or onChange, and that function does the work: toggles a class, sets a CSS variable, calls a function your page already has.
  • One DeckRemote.init per page. Put every control in the same actions object.
  • Keep the last line. The script tag is async, so DeckRemote may not exist yet when your script runs. The last line waits for it.
  • Controls work from the keyboard too. Give a control a key and 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.

kindOn the phoneYour page suppliesGood for
buttona tile you taprun()one-off actions: reveal, jump, reset, play an animation
momentaryacts only while heldonPress(), onRelease()spotlight, hold-to-reveal
toggleon / off, lit when ononChange(on)theme, notes, an overlay
choicea row of optionsoptions, onChange(value)a mode, a section, a variant
numbervalue, − / + and a slidermin, max, step, onChange(n)text size, zoom, speed
texta field and SendonChange(text)a question or message on screen
timerits own card: start, pause, reset, ± 30 s, presetsseconds; optional onTick(ms), onDone()exercises and breaks (one per page)
readouta value, no inputvalue: () => 'text'something you want to see but not change

Fields every kind accepts

FieldMeaning
labelWhat the phone shows. Keep it under about 20 characters, and name the thing, not its state: "Speaker notes", not "Notes on/off".
keyA keyboard shortcut on the laptop, e.g. 'n' or 'shift+n'.
groupA folding section on the phone, e.g. 'Look'. Use groups once you have more than a handful of controls.
confirm: trueThe phone asks before firing. Use it for anything you'd regret tapping mid-talk.
iconThe tile's icon: x, one, eye, star, shuffle, note, moon, blank, timer, play, reset, jump, present.
valueThe starting value of a toggle, choice or number.

Extra fields for some kinds

  • choice: options is a list of strings, or of { value, label } objects. Add color: '#4f7cff' to an option and the phone shows a colour swatch instead of the word. compact: true lets 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.

audienceResultsAudience 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.

QUnlock, or pair a phone as the remote
Shift + QDisconnect the current phone and pair again
AShow the audience join code
IPoll on this slide: open → close → reveal
MPoll on this slide: switch who sees results (before opening)
?List every shortcut on this page
EscClose 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; text and readout values up to 200. Longer values are cut rather than rejected.
  • A readout refreshes 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 kind is 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 missing deck function. Also check that focus isn't in a text field.
  • A control doesn't appear on the phone. Check that DeckRemote.init runs only once and that every control has a kind and a label. 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.