opsinjs
ComponentsHealth input

Questionnaire

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.

Preview

Theme
Density
Text
Status
questionnaire · base base · style base-lyraOpen under the product theme

Installation

pnpm dlx shadcn@latest add @opsinjs/questionnaire

The @opsinjs namespace is declared in your components.json. Everything it installs is code you then own. There is no runtime package to keep in step.

Usage

import { Questionnaire } from "@/components/ui/questionnaire"
<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

Use it when

  • A set of questions the product owns, read as one group and answered in order.
  • A short form that needs one accessible name, a progress readout and one completion signal, with the product supplying each control.
  • The questions of an instrument the product licenses and scores itself, every clinical decision kept on its side.

Do not use it when

  • One question with one answer, such as a single rating or a yes or no. A set of one needs no shell.Use field instead.
  • A set of symptoms collected as a multi-select grid rather than a sequence of separate questions.Use symptom-picker instead.

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 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 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 is why an instrument's wording and scoring stay with its licensee.

Anatomy

  1. QuestionnaireThe root form. Named by the title, it cancels submit and calls onComplete. Neutral chrome only.Controlled by questions
  2. Questionnaire.TitleThe set's name and the form's accessible name. Rendered only when title is supplied.Controlled by title
  3. Questionnaire.ProgressThe 'Question N of M' readout, an aria-live region that follows focus. Never coloured.Controlled by showProgress
  4. Questionnaire.QuestionOne list item per question, under a hairline. Updates the progress index on focus.
  5. Questionnaire.PromptThe question text, in the product's words. Shown, never edited.
  6. Questionnaire.ControlThe product's answer control, such as a Field or RadioGroup. Rendered untouched, its value never read.
  • Questionnaire 1A form. Neither data-status nor data-category.
    • Questionnaire.Title 0..1The form's accessible name, when title is supplied.
    • Questionnaire.Progress 0..1aria-live="polite", when showProgress is set.
    • Questionnaire.Question 1..nOne li per question.
      • Questionnaire.Prompt 1Product-owned text.
      • Questionnaire.Control 1Rendered untouched.

Examples

A set of questions

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

Theme
Density
Text
Status
questionnaire-a-set-of-questions · base base · style base-lyraOpen under the product theme

Showing progress

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

Theme
Density
Text
Status
questionnaire-showing-progress · base base · style base-lyraOpen under the product theme

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.

Do

The product's own questions under one title, with scoring done on a separate surface.

Don’t

A running total or a "your result" line inside the questionnaire. It turns a container into a verdict.

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.

KeyActionNotes
TabMoves to the next control in the setNo tab stop of its own. Entering a question updates the readout.
Shift+TabMoves to the previous controlSame in reverse. The title and the readout are not focusable.
ReturnSubmits the set, which the shell turns into onCompleteDefault navigation is cancelled, so Return never reloads the form.
Arrow keys, SpaceBelong to the control in focusThe shell captures neither. Each key is the product control's contract.
PairThemeAPCA LcWCAG 2.2Floor
body text on the pagelight101.617.18:1Pass
body text on the pagedark-100.518.00:1Pass
body text on a cardlight104.717.96:1Pass
body text on a carddark-99.616.32:1Pass
body text on the muted groundlight98.116.32:1Pass
body text on the muted grounddark-97.513.77:1Pass
secondary text on the pagelight83.97.42:1Pass
secondary text on the pagedark-80.713.44:1Pass
secondary text on a cardlight87.07.76:1Pass
secondary text on a carddark-79.812.19:1Pass
secondary text on the muted groundlight80.47.05:1Pass
secondary text on the muted grounddark-77.810.28:1Pass
a hairline boundary on the pagelight19.31.41:1Below floor
a hairline boundary on the pagedark-8.21.90:1Below floor
a hairline boundary on a cardlight22.41.47:1Below floor
a hairline boundary on a carddark-7.31.72:1Below floor
a hairline boundary on the muted groundlight15.81.34:1Below floor
a hairline boundary on the muted grounddark0.01.45:1Below floor
a placeholder boundary on the pagelight69.84.52:1Pass
a placeholder boundary on the pagedark-51.07.62:1Pass
a placeholder boundary on a cardlight72.84.72:1Pass
a placeholder boundary on a carddark-50.16.91:1Pass
a placeholder boundary on the muted groundlight66.24.29:1Pass
a placeholder boundary on the muted grounddark-48.15.83:1Pass
a placeholder fill on the pagelight8.31.18:1Below floor
a placeholder fill on the pagedark-8.21.90:1Below floor
a placeholder fill on a cardlight11.41.23:1Below floor
a placeholder fill on a carddark-7.31.72:1Below floor
a placeholder fill on the muted groundlight0.01.12:1Below floor
a placeholder fill on the muted grounddark0.01.45:1Below floor
a placeholder boundary on its own filllight58.93.84:1Pass
a placeholder boundary on its own filldark-41.54.01:1Below floor
the card hairline on the pagelight69.84.52:1Pass
the card hairline on the pagedark-28.74.19:1Below floor
the card hairline on a cardlight72.84.72:1Pass
the card hairline on a carddark-27.93.80:1Below floor

These are the measured token pairs this component draws colour from, not a measurement of the component itself.

Data attributes

AttributeConditionValue
data-slotOn the root and every inner part, alwaysquestionnaire, questionnaire-title, questionnaire-progress, questionnaire-question, questionnaire-prompt, questionnaire-control
data-question-indexOn every question list item, alwaysThe question's zero-based position in the questions array

API reference

Prop

Type

Generated from QuestionnaireProps in registry/bases/base/questionnaire.tsx.

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.

  • Field is one question with one answer. A set of one needs no shell.
  • SymptomPicker collects symptoms as a multi-select grid, not a sequence.
  • LogSheet records repeated readings over time. A questionnaire gathers a one-off set and keeps none.

On this page