---
title: "A front-desk kiosk — React"
description: "A tablet viewer for filling in one form at a front desk, with a header of its own, three finger-sized buttons, a check of the required answers, and nothing else to tap."
integration: "React"
source: "https://www.embedpdf.com/docs/viewer/react/examples/kiosk"
---

# A front-desk kiosk

For a tablet on a counter: someone fills in an application and hands it back. The form is all
there is, under a header with the desk's name, the question being answered and how many required
answers are in. Three buttons at the bottom, each a finger's size, put the cursor in the next empty
box, clear the form for the next person, and check it: **Done** lists what's still missing, or
locks the finished form and says thank you.

**`kiosk.tsx`**

```tsx
import { useState } from 'react';
import {
  PDFViewer,
  Panel,
  Region,
  type Viewer,
  type ViewerConfig,
  type ViewerPlugins,
} from '@embedpdf/viewer-react';

import './kiosk.css';

const forms = '/pdfs/forms.pdf';

type Form = ViewerPlugins['form'];

/** A question on the form: one of its fields. */
type Question = ReturnType<Form['list']>[number];

/** How far along the form is: how many questions are required, and which of them are empty. */
interface Answers {
  readonly required: number;
  readonly missing: readonly Question[];
}

const WELCOME = 'Tap a box to answer it, or tap Next field.';

/** People fill in the form, and nothing else changes it. */
const FILLING_ONLY = { except: ['forms.filling'] };

/** What the form calls a question: its tooltip, else its field's name. */
const labelOf = (question: Question) => question.alternateName ?? question.name;

/** Whether a value from `exportValues()` is an answer: text, a choice, a ticked box. */
const isAnswer = (value: unknown) => (Array.isArray(value) ? value.length > 0 : Boolean(value));

const answersOf = (form: Form): Answers => ({
  required: form.list().filter((field) => field.required).length,
  missing: form.validate().missing,
});

// What the commands read from the app: whether the form is done, and how to change that and the
// header's line.
interface Kiosk {
  /** Whether the form is complete and handed in: it's locked until Clear. */
  sent: boolean;
  setSent(sent: boolean): void;
  /** Write the header's line. */
  say(line: string): void;
}

// The front desk's viewer: the form, a header and three buttons, nothing else. A constant: the
// commands read the app from `appState`, so they never hold an old value.
const config: ViewerConfig<Kiosk> = {
  theme: {
    preference: 'light',
    accent: '#b5500e',
    tokens: {
      background: '#f3e7dc',
      surface: '#fffaf5',
      'surface-raised': '#fffaf5',
      text: '#2b1c12',
      'text-muted': '#6e5848',
      border: '#e3d5c9',
      hover: '#fdefe1',
      canvas: '#efe2d3',
      radius: '14px',
      font: "ui-rounded, 'SF Pro Rounded', 'Segoe UI', system-ui, sans-serif",
    },
  },
  // Every button and menu row a finger's size, even with a mouse plugged in.
  touch: true,
  // A kiosk fills in forms all day and never keeps them: leaving the page asks nothing.
  warnBeforeLeaving: false,
  // No opening, saving or printing files, no search, and no selecting text: a long press is only
  // ever a tap on the form.
  disable: ['documents', 'search', 'text'],
  plugins: {
    stage: { zoom: { mode: 'fit-width' } },
    form: { fields: { background: 'rgb(253 239 225 / 0.9)' } },
  },
  icons: {
    next: ['M5 12l14 0', 'M13 18l6 -6', 'M13 6l6 6'],
    clear: [
      'M19 20h-10.5l-4.21 -4.3a1 1 0 0 1 0 -1.41l10 -10a1 1 0 0 1 1.41 0l5 5a1 1 0 0 1 0 1.41l-9.2 9.3',
      'M18 13.3l-6.3 -6.3',
    ],
    done: ['M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0', 'M9 12l2 2l4 -4'],
  },
  commands: [
    {
      id: 'kiosk:next',
      label: 'Next field',
      icon: 'next',
      // The header names the field once the cursor is in it.
      run: ({ appState, viewer }) => {
        if (appState.sent) {
          appState.say('This form is done. Tap Clear for the next person.');
        } else if (!viewer.get('form').focusNext({ empty: true, wrap: true })) {
          appState.say('Every question has an answer. Tap Done.');
        }
      },
    },
    {
      id: 'kiosk:clear',
      label: 'Clear',
      icon: 'clear',
      enabled: ({ get }) => Object.values(get('form').exportValues()).some(isAnswer),
      run: async ({ appState, viewer }) => {
        appState.setSent(false);
        await viewer.get('form').reset();
        viewer.get('stage').goToPage(0);
        appState.say('Cleared, ready for the next person.');
      },
    },
    {
      id: 'kiosk:done',
      label: 'Done',
      icon: 'done',
      run: ({ appState, viewer }) => {
        const { valid } = viewer.get('form').validate();
        appState.setSent(valid);
        appState.say(valid ? 'All done. Thank you!' : 'A few answers are still missing.');
        viewer.get('shell').open('kiosk-check');
      },
    },
  ],
  // A layout of its own: one bar at the bottom with the three buttons and their labels, Done
  // filled in, and the check as a sheet.
  layout: {
    bars: {
      kiosk: {
        place: 'bottom',
        center: [
          [
            { command: 'kiosk:next', variants: ['icon+label', 'icon'] },
            { command: 'kiosk:clear', variants: ['icon+label', 'icon'] },
            { command: 'kiosk:done', emphasis: 'primary', variants: ['icon+label', 'icon'] },
          ],
        ],
      },
    },
    panels: {
      'kiosk-check': { title: 'Your application', present: 'sheet', resizable: false },
    },
  },
};

export default function App() {
  const [viewer, setViewer] = useState<Viewer | null>(null);
  const [answers, setAnswers] = useState<Answers | null>(null);
  const [line, setLine] = useState(WELCOME);
  const [sent, setSent] = useState(false);

  const onReady = (viewer: Viewer) => {
    setViewer(viewer);
    // The form is the document's: `watch` waits for it to open, then follows it as people fill
    // it in, for the header's count and the check.
    viewer.watch(
      () => viewer.get('form').list(),
      () => setAnswers(answersOf(viewer.get('form'))),
      { immediate: true },
    );
    // The header's line names the question the cursor is in, whether Next field or a tap put it
    // there.
    viewer.documents.onOpened(() => {
      viewer.get('form').onFocusChanged(({ field }) => {
        if (field) setLine(`Answering: ${labelOf(field)}`);
      });
    });
  };

  /** Close the check, and put the cursor in a question it lists. */
  const pick = (question: Question) => {
    if (!viewer) return;
    viewer.get('shell').close('kiosk-check');
    viewer.get('form').focus(question.ref);
  };

  const answered = answers ? answers.required - answers.missing.length : 0;
  return (
    <PDFViewer
      src={forms}
      {...config}
      appState={{ sent, setSent, say: setLine }}
      readOnly={sent ? true : FILLING_ONLY}
      onReady={onReady}
      style={{ height: '100%' }}
    >
      <Region name="header">
        <header className="kiosk-header">
          <p className="kiosk-brand">
            Lanternfield <small>Front desk</small>
          </p>
          <p className="kiosk-line" aria-live="polite">
            {line}
          </p>
          {answers && answers.required > 0 && (
            <p className={answers.missing.length ? 'kiosk-progress' : 'kiosk-progress complete'}>
              {answered} of {answers.required} required
            </p>
          )}
        </header>
      </Region>
      <Panel id="kiosk-check">
        {sent ? (
          <div className="kiosk-check">
            <h2>Thank you!</h2>
            <p>Your application is complete. Please hand the tablet back to the front desk.</p>
          </div>
        ) : (
          <div className="kiosk-check">
            <h2>Almost there</h2>
            <p>These questions still need an answer:</p>
            <ul>
              {answers?.missing.map((question) => (
                <li key={question.name}>
                  <button type="button" onClick={() => pick(question)}>
                    {labelOf(question)}
                  </button>
                </li>
              ))}
            </ul>
          </div>
        )}
      </Panel>
    </PDFViewer>
  );
}
```

**`kiosk.css`**

```css
/* The kiosk's own parts, in the viewer's places: the header, and what's in the check sheet. They
   use the viewer's colors (its theme's tokens), so one theme colors both. The bottom bar's
   buttons are the viewer's own, made big through their parts. */

/* The header */
.kiosk-header {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 12px 24px;
  padding: 16px 28px;
  background: var(--epdf-surface);
  border-bottom: 1px solid var(--epdf-border);
}

.kiosk-brand {
  display: flex;
  align-items: center;
  gap: 12px;
  margin: 0;
  font-size: 20px;
  font-weight: 700;
  color: var(--epdf-text);
}

/* A lantern: the body, with a cap at each end and the flame inside. */
.kiosk-brand::before {
  content: '';
  width: 24px;
  height: 22px;
  border-block: 5px solid var(--epdf-text);
  border-radius: 7px;
  background:
    radial-gradient(circle at 50% 55%, #f2b27c 0 4px, transparent 5px), var(--epdf-accent);
}

.kiosk-brand small {
  font-size: 15px;
  font-weight: 500;
  color: var(--epdf-text-muted);
}

.kiosk-line {
  flex: 1 1 240px;
  margin: 0;
  font-size: 18px;
  text-align: center;
  color: var(--epdf-text);
}

.kiosk-progress {
  margin: 0;
  padding: 8px 16px;
  font-size: 16px;
  font-weight: 700;
  white-space: nowrap;
  color: var(--epdf-accent);
  background: var(--epdf-hover);
  border-radius: 999px;
}

.kiosk-progress.complete {
  color: var(--epdf-surface);
  background: var(--epdf-accent);
}

/* The bottom bar: big outlined pills, Done filled in. The kiosk's only bar, so these are its
   three buttons. */
embedpdf-viewer {
  /* Sized by the viewer's own width, as the viewer sizes itself: smaller on a phone. */
  container-type: inline-size;
}

embedpdf-viewer::part(toolbar-button) {
  min-width: 184px;
  height: 72px;
  margin: 8px;
  padding: 0 28px;
  font-weight: 700;
  border: 2px solid var(--epdf-border);
  border-radius: 999px;
}

embedpdf-viewer::part(toolbar-button-primary) {
  border-color: var(--epdf-accent);
}

/* On a phone, three buttons still fit. */
@container (width < 600px) {
  embedpdf-viewer::part(toolbar-button) {
    min-width: 0;
    height: 60px;
    margin: 6px 4px;
    padding: 0 16px;
  }
}

/* The check, in its sheet */
.kiosk-check {
  max-width: 560px;
  margin: 0 auto;
  padding: 4px 20px 24px;
  text-align: center;
}

.kiosk-check h2 {
  margin: 4px 0 8px;
  font-size: 28px;
  color: var(--epdf-text);
}

.kiosk-check p {
  margin: 0 0 20px;
  font-size: 18px;
  color: var(--epdf-text-muted);
}

.kiosk-check ul {
  display: grid;
  gap: 12px;
  margin: 0;
  padding: 0;
  list-style: none;
}

.kiosk-check li button {
  width: 100%;
  min-height: 64px;
  padding: 0 24px;
  font: inherit;
  font-size: 19px;
  font-weight: 600;
  text-align: start;
  color: var(--epdf-text);
  background: var(--epdf-hover);
  border: 2px solid transparent;
  border-radius: var(--epdf-radius);
  cursor: pointer;
}

.kiosk-check li button::after {
  content: '→';
  float: inline-end;
  color: var(--epdf-accent);
}

.kiosk-check li button:focus-visible {
  border-color: var(--epdf-accent);
  outline: none;
}
```

> **Try it:** tap **Next field**: the cursor goes into Full name, and the header names it. Fill it
> in, then tap **Done**: the sheet lists Email and the membership terms, and a tap on one puts the
> cursor in it. Answer both, tap **Done** again, then **Clear** for the next person.

## What it uses

- [A whole layout](../customize/layout#your-whole-layout): one bar at the bottom, and a panel.
- [Its own commands](../customize/commands), with [their own icons](../customize/commands#icons):
  **Next field**, **Clear** and **Done**, with Clear
  [greyed out](../customize/commands#enabled-active-and-visible) while the form is empty. They
  read and change the app's state, whether the form is done and the header's line, through
  [`appState`](../customize/commands#your-apps-state).
- The bar shows them as the viewer's buttons with
  [their labels](../customize/layout#when-theres-no-room), **Done** as its
  [primary button](../customize/layout#a-primary-button), made big outlined pills through
  [their parts](../customize/theming#restyle-a-part), smaller on a phone.
- [`touch: true`](../customize/small-screens#touch): every button and menu row is a finger's size,
  even with a mouse plugged in.
- [The header region](../customize/components#above-below-and-when-nothing-is-open), with the
  desk's name, a line that names the question being answered or says what just happened, and the
  count of required answers.
- [A panel of its own](../customize/panels#your-own-panel) that opens as a
  [sheet](../customize/panels#how-a-panel-opens) at every size, opened and closed
  [from code](../customize/panels#open-a-panel-from-code).
- [The theme's tokens](../customize/theming#every-color) for warm paper, round corners and a rounded
  `font`, which the kiosk's own parts [use too](../customize/theming#in-your-own-components).
- [Read-only, except filling in the form](../features/turning-off#read-only), and
  [read-only](../setup#change-the-config-later) once the form is done, until Clear.
- [Features turned off](../features/turning-off): opening, downloading and printing files, search,
  and selecting text.
- [Plugin settings](../code/plugins#settings): the pages fit the width, and the fields have a tint
  of their own, as [Form settings](../features/forms-and-signatures#form-settings) shows.
- [The form plugin from code](../features/forms-and-signatures#read-and-fill-in-values-from-code):
  [`focusNext()`](../features/forms-and-signatures#take-people-to-a-field) to the next empty field,
  `focus()` to one the sheet lists, `onFocusChanged` for the header's line (see
  [Move between fields](https://www.embedpdf.com/docs/headless/react/forms#move-between-fields)),
  [the required fields](https://www.embedpdf.com/docs/headless/react/forms#check-required-fields) and
  [a reset](https://www.embedpdf.com/docs/headless/react/forms#reset-the-form).
- [`watch()`](../code/viewer#follow-a-value) on the form from `onReady`, which waits for the
  document, for the header's count and the sheet; the cursor's events
  [start from the document](../code/viewer#plugins-come-with-the-document).

* [No question when the page closes](../documents#warn-before-changes-are-lost),
  `warnBeforeLeaving: false`: a kiosk fills in forms all day and never keeps them.
