Skip to content

ExpressiveCSS

Browse documentation

Questionnaire

A form that asks one question at a time, with single and multiple choice, free text and skippable questions.

View as Markdown

Put each question in a <fieldset> inside a <form class="questionnaire">, and add a <footer> with the buttons that move between them. The form asks one question at a time.

Use the <legend> for the prompt and a <p> under it to describe the question. Answers are ordinary radio buttons, checkboxes or text fields. A <small> inside a choice's label describes that choice. Mark the footer buttons by their value: previous, skip and next, plus a submit button. The component adds the progress row and shows only the question and buttons that apply.

Use a questionnaire for a short survey, onboarding or intake form where each question deserves the whole screen. For a long form people need to scan and correct, keep every field on the page. The stepper suits a few large stages, such as a checkout, each with several fields.

What should we prototype next?

Pick the direction you want to explore first.

Choose an answer to continue.

Where do you use the app?

Choose all that apply.

Choose at least one platform.

Anything else we should know?

Optional. Skip it if nothing comes to mind.

<form class="questionnaire" aria-label="Product survey">
  <fieldset>
    <legend>What should we prototype next?</legend>
    <p>Pick the direction you want to explore first.</p>
    <label><input type="radio" name="direction" value="search" required>Search<small>Find any page in two keystrokes</small></label>
    <label><input type="radio" name="direction" value="sharing" required>Sharing<small>Send a view to a teammate</small></label>
    <p class="questionnaire-error">Choose an answer to continue.</p>
  </fieldset>
  <fieldset data-required>
    <legend>Where do you use the app?</legend>
    <label><input type="checkbox" name="platforms" value="web">Web</label>
    <label><input type="checkbox" name="platforms" value="mobile">Mobile</label>
    <p class="questionnaire-error">Choose at least one platform.</p>
  </fieldset>
  <fieldset>
    <legend>Anything else we should know?</legend>
    <div class="field">
      <textarea id="notes" name="notes" class="expressive-textarea" placeholder=" "></textarea>
      <label for="notes">Notes</label>
    </div>
  </fieldset>
  <footer>
    <button type="button" class="text" value="previous">Back</button>
    <button type="button" class="text" value="skip">Skip</button>
    <button type="button" value="next">Next</button>
    <button type="submit">Submit</button>
  </footer>
</form>

Questions

The answers decide the kind of question. Radio buttons with one name make a single choice, and checkboxes make a multiple choice. A text field or textarea takes a free answer, and a question can mix choices with a text field for "Other".

Add required to one of a question's controls to make the question required. HTML has no way for a group of checkboxes to say "at least one", so put data-required on its fieldset instead. Any other question is optional, and the Skip button appears for it. Skipping clears whatever was answered, so the form sends nothing for that question. Leave out the Skip button to make people press Next instead.

Validation

Next checks the shown question with the browser's own validation, so required, pattern, minlength and the input types all work. When the answer is missing or wrong, the fieldset gets invalid and its controls get aria-invalid="true". Its .questionnaire-error shows, and focus moves to the control to fix. Answering clears the error.

Write the error text in the question so it can be specific and in the page's language. A question without one shows the browser's validation message, or the requiredMessage option for a data-required group. Submitting checks every question and opens the first one that still needs an answer, whether it comes from the button, Enter or requestSubmit(). Only enabled checkboxes count toward data-required, since the browser does not send a disabled one.

Conditional questions

To branch, enable or disable questions based on earlier answers. The component skips a disabled fieldset, and the progress count leaves it out. The browser does not send the controls of a disabled fieldset.

<fieldset>
  <legend>Do you work in a team?</legend>
  <label><input type="radio" name="team" value="yes" required>Yes</label>
  <label><input type="radio" name="team" value="no" required>No</label>
</fieldset>
<fieldset id="team-size" disabled>
  <legend>How many people are on your team?</legend>
  …
</fieldset>

<script>
  form.addEventListener('change', () => {
    document.getElementById('team-size').disabled = form.elements.team.value !== 'yes';
  });
</script>

Submitting

The submit button shows on the last question. Pressing it sends the form like any other, to its action or to a submit listener that reads the answers with FormData. Use getAll() for checkboxes. Without JavaScript, every question shows at once, the navigation buttons stay hidden, and the submit button still sends the form. In that case nothing checks a data-required group.

form.addEventListener('submit', (event) => {
  event.preventDefault();
  const answers = new FormData(event.currentTarget);
  answers.get('direction');    // 'search'
  answers.getAll('platforms'); // ['web', 'mobile']
});

Shortcuts, review and saving

Add any of these three attributes to the form:

  • data-shortcuts lets a letter or a digit pick the answer at that place in the shown question, A or 1 for the first. It ends each choice with its letter, which screen readers skip. Keys typed into a text field stay text.
  • data-summary adds a review step after the last question. It lists every question with its answer and an Edit button back to it. The submit button shows there instead.
  • data-save="key" keeps the answers and the shown question in localStorage under that key. It restores them when the page comes back and forgets them once the form is submitted. A restored answer fires change, so conditional questions open as they did. File inputs are not saved. Keep personal answers out of saved questionnaires on shared devices.
How did you hear about us?
What do you use it for?
<form class="questionnaire" aria-label="Shortcut survey" data-shortcuts data-summary data-save="survey">
  <fieldset>
    <legend>How did you hear about us?</legend>
    <label><input type="radio" name="heard" value="search" required>Search engine</label>
    <label><input type="radio" name="heard" value="friend" required>A friend</label>
    <label><input type="radio" name="heard" value="ad" required>An ad</label>
  </fieldset>
  <!-- More questions -->
  <footer>
    <button type="button" class="text" value="previous">Back</button>
    <button type="button" class="text" value="skip">Skip</button>
    <button type="button" value="next">Next</button>
    <button type="submit">Submit</button>
  </footer>
</form>

Accessibility

Each question is a native group named by its legend. The component attaches the description and any error with aria-describedby. Hidden questions use hidden, so they are out of the tab order and the accessibility tree. After Next, Back or Skip, focus moves to the selected answer or the first control of the new question.

Name the form with aria-label or a heading. Enter in a field moves to the next question, and on the last one it submits. The progress row is a <progress> named by its text, "Question 2 of 3".

Options

Name Type Default Description
progressLabel String 'Question {current} of {total}' Text of the progress row. {current} and {total} become the question number and count. data-progress-label on the form sets it.
requiredMessage String 'Choose an answer to continue.' Error for a question with no .questionnaire-error when the browser has no message, as for a data-required group.
summaryLabel String 'Review your answers' With data-summary, the review step's name and the text of the progress row there.
editLabel String 'Edit' With data-summary, the text of each answer's button back to its question.
skippedLabel String 'Not answered' With data-summary, the answer shown for a question left blank.

Methods

const questionnaire = Expressive.Questionnaire.getInstance(document.querySelector('form.questionnaire'));
questionnaire.items;     // the fieldsets that apply, in order
questionnaire.index;     // position of the shown question
questionnaire.next();    // checks the shown question, then moves on
questionnaire.previous();
questionnaire.skip();    // clears the shown answer, then moves on
questionnaire.goTo(2);   // shows a question without checking, e.g. to resume
questionnaire.review();  // with data-summary, shows the review step
questionnaire.destroy(); // shows every question and removes what it added

Tokens

Override these on the .questionnaire.

Token Default
--md-comp-questionnaire-choice-shape12px
--md-comp-questionnaire-choice-outline-color--md-sys-color-outline-variant
--md-comp-questionnaire-choice-selected-color--md-sys-color-secondary-container

No matching pages

  • Source color

    The seed every generated ramp derives from. Pick one and browse the docs — the whole theme follows. Error does not: it is a fixed hue.

    Default source: Mist Iris.

    ExpressiveCSS presets: Lagoon, Harbor, Breeze, Cobalt, Iris.

    Bold

    Soft

    Mist

    Glow