---
title: "Questionnaire"
description: "The styled form shell that groups a product's own questions and shows how far through them a reader is, while scoring and interpreting nothing clinical."
url: "https://opsinjs.pensievelabs.org/components/questionnaire"
source: "https://opsinjs.pensievelabs.org/components/questionnaire.md"
section: "Components"
status: "shipped"
kind: "component"
category: "health-input"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["survey", "assessment", "phq", "screening form"]
governedBy: ["clinical-interaction-guidelines", "regulatory-context", "evidence-and-references", "who-this-is-for"]
implemented: true
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> `<StubNotice>` IS THE EXCEPTION, AND IT IS THE ONE TO READ. It is a
> paired element rather than a self-closing one, and the text between
> its opening and closing tags is prose an author wrote, reproduced
> below word for word. That prose is where this page says whether the
> component has been reviewed. Read the children, not only the
> attributes.

<StubNotice
  name="questionnaire"
  status="shipped"
  questions="[
  &#x22;No contrast pair has been measured: the hairline, the title ink and the muted progress ink all sit on whatever ground the host provides.&#x22;,
  &#x22;Nobody has listened to the aria-live progress readout, so whether 'Question 2 of 3' on every focus move helps a reader or talks over them is unheard.&#x22;,
  &#x22;The refusal to score binds only the shell: nothing stops a product wiring a running total into its own controls, and no lint on consumer code catches it.&#x22;,
]"
>
  Audited against WCAG 2.2 AA. Clinical review pending.
</StubNotice>

## Preview [#preview]

<ComponentPreview name="questionnaire" />

## Installation [#installation]

<ComponentInstall name="questionnaire" unbuilt="false" importPath="@/components/ui/questionnaire" />

## Usage [#usage]

```tsx
import { Questionnaire } from "@/components/ui/questionnaire"
```

```tsx
<Questionnaire
  title="Weekly check-in"
  showProgress
  onComplete={handleComplete}
  questions={[
    { id: "rest", prompt: "How rested did you feel this week?", control: <RestControl /> },
    { id: "plan", prompt: "How easy was it to keep to your plan?", control: <PlanControl /> },
  ]}
/>
```

## When to use it [#when-to-use-it]

<WhenToUse
  use="[
  &#x22;A set of questions the product owns, read as one group and answered in order.&#x22;,
  &#x22;A short form that needs one accessible name, a progress readout and one completion signal, with the product supplying each control.&#x22;,
  &#x22;The questions of an instrument the product licenses and scores itself, every clinical decision kept on its side.&#x22;,
]"
  avoid="[
  { case: &#x22;One question with one answer, such as a single rating or a yes or no. A set of one needs no shell.&#x22;, instead: &#x22;field&#x22; },
  { case: &#x22;A set of symptoms collected as a multi-select grid rather than a sequence of separate questions.&#x22;, instead: &#x22;symptom-picker&#x22; },
]"
/>

## Clinical meaning [#clinical-meaning]

**Asserts.** Two things about the form, not the person: these questions, in this order, and how far through them you are. The progress count is an ordinal of position, drawn as plain text.

**Never read as.** A score, a screening result, a diagnosis or triage. A questionnaire on screen means the product has asked, not that it has assessed. The shell draws whatever wording it is handed, so a product that edits or misgrades a licensed instrument owns that hazard. [Regulatory context](../health/regulatory-context.mdx) draws that line around a presentation layer.

**Colour axis.** Neither. The shell states no level and names no category, so it carries neither `data-status` nor `data-category`. [The two colour axes](../health/two-colour-axes.mdx) forbids the mix.

**Thresholds.** The product, always. opsinjs ships no instrument, no default question, no option, no threshold and no interpretation text. [Evidence and references](../health/evidence-and-references.mdx) is why an instrument's wording and scoring stay with its licensee.

## Anatomy [#anatomy]

<Anatomy
  name="questionnaire"
  parts="[
  {
    name: &#x22;Questionnaire&#x22;,
    describes: &#x22;The root form. Named by the title, it cancels submit and calls onComplete. Neutral chrome only.&#x22;,
    prop: &#x22;questions&#x22;,
  },
  {
    name: &#x22;Questionnaire.Title&#x22;,
    describes: &#x22;The set's name and the form's accessible name. Rendered only when title is supplied.&#x22;,
    prop: &#x22;title&#x22;,
  },
  {
    name: &#x22;Questionnaire.Progress&#x22;,
    describes: &#x22;The 'Question N of M' readout, an aria-live region that follows focus. Never coloured.&#x22;,
    prop: &#x22;showProgress&#x22;,
  },
  {
    name: &#x22;Questionnaire.Question&#x22;,
    describes: &#x22;One list item per question, under a hairline. Updates the progress index on focus.&#x22;,
  },
  {
    name: &#x22;Questionnaire.Prompt&#x22;,
    describes: &#x22;The question text, in the product's words. Shown, never edited.&#x22;,
  },
  {
    name: &#x22;Questionnaire.Control&#x22;,
    describes: &#x22;The product's answer control, such as a Field or RadioGroup. Rendered untouched, its value never read.&#x22;,
  },
]"
/>

<CompositionTree
  name="questionnaire"
  tree="[
  {
    part: &#x22;Questionnaire&#x22;,
    cardinality: &#x22;1&#x22;,
    note: &#x22;A form. Neither data-status nor data-category.&#x22;,
    children: [
      {
        part: &#x22;Questionnaire.Title&#x22;,
        cardinality: &#x22;0..1&#x22;,
        note: &#x22;The form's accessible name, when title is supplied.&#x22;,
      },
      {
        part: &#x22;Questionnaire.Progress&#x22;,
        cardinality: &#x22;0..1&#x22;,
        note: &#x22;aria-live=\&#x22;polite\&#x22;, when showProgress is set.&#x22;,
      },
      {
        part: &#x22;Questionnaire.Question&#x22;,
        cardinality: &#x22;1..n&#x22;,
        note: &#x22;One li per question.&#x22;,
        children: [
          {
            part: &#x22;Questionnaire.Prompt&#x22;,
            cardinality: &#x22;1&#x22;,
            note: &#x22;Product-owned text.&#x22;,
          },
          {
            part: &#x22;Questionnaire.Control&#x22;,
            cardinality: &#x22;1&#x22;,
            note: &#x22;Rendered untouched.&#x22;,
          },
        ],
      },
    ],
  },
]"
/>

## Examples [#examples]

### A set of questions [#a-set-of-questions]

Three invented questions under one title, each with a control the product composed.

<ComponentPreview name="questionnaire-a-set-of-questions" kind="example" align="start" />

### Showing progress [#showing-progress]

`showProgress` turns on the readout above the list. It follows focus, so entering the second question reads "Question 2 of 3".

<ComponentPreview name="questionnaire-showing-progress" kind="example" align="start" />

## Content guidelines [#content-guidelines]

Write every question and option in the reader's own words. Render a licensed instrument exactly as licensed, because the shell cannot tell an edited item from the original. Keep the title a short name, not an instruction.

<DoDont>
  <DoDont.Do>
    **The product's own questions under one title**, with scoring done on a separate surface.
  </DoDont.Do>

  <DoDont.Dont>
    **A running total or a "your result" line inside the questionnaire.** It turns a container into a verdict.
  </DoDont.Dont>
</DoDont>

## Accessibility [#accessibility]

**Audited against WCAG 2.2 AA, in a source pass and a rendered pass.** The audit is author-run, not independent, and clinical review is pending.

**Fixed in this pass.**

* Every prompt carries a stable id, and each control sits in a `role="group"` named by that prompt through `aria-labelledby`.
* The default demo's free-text control gained an `aria-label`, so it is no longer unnamed.

**Neutral chrome, checked on every commit.**

* No status colour, no category colour, no colour literal and no ramp step.
* No `px` type size. Title, prompts and readout are semantic type steps.

**Known and deferred.**

* The shell cannot name a product's own control node, so a product that passes a bare input still labels it, with the per-question group supplying context.
* The title is the form's `aria-labelledby`, drawn as a styled div, because the shell cannot know the host page's heading level. A visible heading on the product's side is recommended.
* The progress line is `aria-live="polite"`, announced as focus moves. It may read as chatty, which is a tuning question for the product.

**Not measured.** Every contrast pair against the host ground, and `forced-colors: active`.

<KeyboardTable
  name="questionnaire"
  rows="[
  {
    keys: &#x22;Tab&#x22;,
    action: &#x22;Moves to the next control in the set&#x22;,
    notes: &#x22;No tab stop of its own. Entering a question updates the readout.&#x22;,
  },
  {
    keys: &#x22;Shift + Tab&#x22;,
    action: &#x22;Moves to the previous control&#x22;,
    notes: &#x22;Same in reverse. The title and the readout are not focusable.&#x22;,
  },
  {
    keys: &#x22;Return&#x22;,
    action: &#x22;Submits the set, which the shell turns into onComplete&#x22;,
    notes: &#x22;Default navigation is cancelled, so Return never reloads the form.&#x22;,
  },
  {
    keys: &#x22;Arrow keys, Space&#x22;,
    action: &#x22;Belong to the control in focus&#x22;,
    notes: &#x22;The shell captures neither. Each key is the product control's contract.&#x22;,
  },
]"
/>

<ContrastReport component="questionnaire" />

## Data attributes [#data-attributes]

<DataAttributesTable
  name="questionnaire"
  rows="[
  {
    attribute: &#x22;data-slot&#x22;,
    condition: &#x22;On the root and every inner part, always&#x22;,
    value: &#x22;questionnaire, questionnaire-title, questionnaire-progress, questionnaire-question, questionnaire-prompt, questionnaire-control&#x22;,
  },
  {
    attribute: &#x22;data-question-index&#x22;,
    condition: &#x22;On every question list item, always&#x22;,
    value: &#x22;The question's zero-based position in the questions array&#x22;,
  },
]"
/>

## API reference [#api-reference]

<PropsTable name="QuestionnaireProps" />

`onComplete` is told that the reader finished and nothing else, because the shell reads no answer. Submit is cancelled even when it is omitted. An empty `questions` array renders nothing and warns in development, as does a duplicate `id` or a missing `title`.

## Related [#related]

* [Field](./field.mdx) is one question with one answer. A set of one needs no shell.
* [SymptomPicker](./symptom-picker.mdx) collects symptoms as a multi-select grid, not a sequence.
* [LogSheet](./log-sheet.mdx) records repeated readings over time. A questionnaire gathers a one-off set and keeps none.
