Kanzo UI
Forms

Questionnaire

A multi-step form that asks its questions one at a time, with progress, validation and navigation.

Ark UI
Question 1 of 4
How did it end?Q-1072 · Map the drowned lane

Usage

import {
  Question,
  QuestionChoice,
  QuestionChoices,
  QuestionPrompt,
  Questionnaire,
  QuestionnaireActions,
  QuestionnaireNext,
  QuestionnairePrevious,
  QuestionnaireProgress,
  QuestionnaireSubmit,
} from "@kanzo-tech/ui";
<Questionnaire onSubmit={(answers) => file(answers)}>
  <QuestionnaireProgress />
  <Question name="outcome" required>
    <QuestionPrompt>How did it end?</QuestionPrompt>
    <QuestionChoices>
      <QuestionChoice description="Delivered, verified, and paid." value="settled">
        Settled
      </QuestionChoice>
      <QuestionChoice description="The party did not come back." value="failed">
        Failed
      </QuestionChoice>
    </QuestionChoices>
  </Question>
  <QuestionnaireActions>
    <QuestionnairePrevious />
    <QuestionnaireNext />
    <QuestionnaireSubmit />
  </QuestionnaireActions>
</Questionnaire>

Anatomy

Questionnaire
├── QuestionnaireProgress
├── Question …
└── QuestionnaireActions
    ├── QuestionnairePrevious
    ├── QuestionnaireSkip
    ├── QuestionnaireNext
    └── QuestionnaireSubmit

Each Question is the question part — the same part a guided chat asks one of per turn. This page is the sequence around it.

What the root owns, and what it does not

The root owns the order of the questions, which one is active, the answers, validation, progress and navigation. That is the whole of it.

Everything around those is the parent's: whether the form is on screen at all, what happens to the payload, whether an answer routes somewhere else. A questionnaire that decided when it appeared could not be put in a dialog, a sidebar and a page, and every product wants it in a different one — so onSubmit hands you the answers and stops there.

Resuming a half-finished session is that division in one line: the parent stored the answers and the position, and hands both back as defaultAnswers and defaultStep.

<Questionnaire defaultAnswers={saved.answers} defaultStep={saved.step} onAnswersChange={save}>

There is no navigation state machine here. Ark's Steps machine already owns the step index, hasNextStep / hasPrevStep, the percentage and — the part that is easy to miss — the guards that stop Next. A required question blocking and a skippable one not blocking are two callbacks into that machine, not two branches of ours.

What a question adds in a sequence

A question's own props are the whole vocabulary, and the root reads them off each Question in source order, so it never needs a second list beside the ones you wrote:

  • required refuses Next until the question is answered — a non-blank string, or at least one selection. It does not disable the button: a disabled control says nothing about what is missing. Pressing Next reveals the question's QuestionError instead, and answering clears it.
  • skippable draws QuestionnaireSkip on that question alone, and lets Next through with an empty answer.
  • name is the key the answer is filed under in the payload.

A QuestionError given children says them instead of the default sentence:

<QuestionError>Name at least one — “nothing” is not actionable.</QuestionError>

Single and multiple choice, a typed line or paragraph, and what a choice looks like are the question part's.

Shortcuts

shortcuts puts 1–9 on the visible question's choices, in order, and prints the key on a Kbd chip on each card so it is visible rather than folklore.

It is opt-in and has no default, because it registers a window listener on bare, unmodified keys and a design system does not get to claim those in your keymap without being asked. Typing in a text control is ignored; a focused radio or checkbox is not — both are <input> elements, and treating them the same would switch the shortcuts off the moment somebody clicked an option.

Progress

QuestionnaireProgress is a reading, not a bar: Question 2 of 4, small and muted, above the question. It is still a role="progressbar" — counting questions rather than percent, with aria-valuetext carrying the same sentence and aria-live="polite" announcing the move — so the position is machine-readable without a trough on screen. Ark's Progress is not underneath it, because zag puts the role on the track: a progress with no painted track has no role at all.

Its sentence is translations.progress, a function of the position and the count, so the eye and aria-valuetext read the same words in every language.

Translations

Every word the questionnaire writes is an entry of the root's translations, its questions' included: progress(position, count), navigation (the actions row's name), previous, skip, next, submit, and the questions' unanswered. A question's own translations win for that question, and an action given children says those instead.

<Questionnaire
  translations={{
    progress: (position, count) => `Pregunta ${position} de ${count}`,
    navigation: "Navegación del cuestionario",
    previous: "Atrás",
    skip: "Omitir",
    next: "Siguiente",
    submit: "Enviar",
    unanswered: "Responde para seguir.",
  }}
>

Actions

QuestionnaireActions is a three-column row — Back at the start, Skip and Next at the end — and it needs an accessible name, for which it supplies a default. It is deliberately not a ButtonGroup: that collapses the inner radii and overlaps its children by a pixel, which is right for a cluster acting on one thing and wrong for the two ends of a journey. The columns hold their places while Back and Skip come and go, so nothing slides sideways between questions.

QuestionnairePrevious draws itself only where there is somewhere to go back to, so the first question's row is a single solid Next and nothing else. QuestionnaireNext and QuestionnaireSubmit are a pair rather than a choice: Next stands down on the last question and Submit takes its place in the same column, so writing both is what makes the row complete.

API Reference

Questionnaire

Extends ark.form (minus onSubmit, which is redefined).

PropTypeDefault
defaultAnswersQuestionnaireAnswers{}
defaultStepnumber0
onAnswersChange(answers) => void—
onStepChange(details) => void—
onSubmit(answers) => void—
shortcutsbooleanfalse
translationsPartial<QuestionnaireTranslations>English

The payload is QuestionnaireAnswers, a record of QuestionAnswers keyed by each question's name.

The rest

QuestionnaireProgress is a <div> carrying the progressbar role, and the four action parts extend Button — so variant, size and children are yours on every one of them.

useQuestionnaire reads the root's context, for a part of your own that has to know where it is: the answers, the ordered questions (each a QuestionnaireQuestion — name, multiple, required, skippable), Ark's steps API and the resolved translations. Inside a question, useQuestion is the question part's.

QuestionnaireProps and QuestionnaireActionsProps are exported, so a wrapper can take the same props without restating them.

On this page