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
questionnaire · base base · style base-lyraOpen under the product themeInstallation
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
fieldinstead. - A set of symptoms collected as a multi-select grid rather than a sequence of separate questions.Use
symptom-pickerinstead.
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
QuestionnaireThe root form. Named by the title, it cancels submit and calls onComplete. Neutral chrome only.Controlled byquestionsQuestionnaire.TitleThe set's name and the form's accessible name. Rendered only when title is supplied.Controlled bytitleQuestionnaire.ProgressThe 'Question N of M' readout, an aria-live region that follows focus. Never coloured.Controlled byshowProgressQuestionnaire.QuestionOne list item per question, under a hairline. Updates the progress index on focus.Questionnaire.PromptThe question text, in the product's words. Shown, never edited.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.
questionnaire-a-set-of-questions · base base · style base-lyraOpen under the product themeShowing progress
showProgress turns on the readout above the list. It follows focus, so entering the second question reads "Question 2 of 3".
questionnaire-showing-progress · base base · style base-lyraOpen under the product themeContent 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 througharia-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
pxtype 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.
| Key | Action | Notes |
|---|---|---|
| Tab | Moves to the next control in the set | No tab stop of its own. Entering a question updates the readout. |
| Shift+Tab | Moves to the previous control | Same in reverse. The title and the readout are not focusable. |
| Return | Submits the set, which the shell turns into onComplete | Default navigation is cancelled, so Return never reloads the form. |
| Arrow keys, Space | Belong to the control in focus | The shell captures neither. Each key is the product control's contract. |
| Pair | Theme | APCA Lc | WCAG 2.2 | Floor |
|---|---|---|---|---|
| body text on the page | light | 101.6 | 17.18:1 | Pass |
| body text on the page | dark | -100.5 | 18.00:1 | Pass |
| body text on a card | light | 104.7 | 17.96:1 | Pass |
| body text on a card | dark | -99.6 | 16.32:1 | Pass |
| body text on the muted ground | light | 98.1 | 16.32:1 | Pass |
| body text on the muted ground | dark | -97.5 | 13.77:1 | Pass |
| secondary text on the page | light | 83.9 | 7.42:1 | Pass |
| secondary text on the page | dark | -80.7 | 13.44:1 | Pass |
| secondary text on a card | light | 87.0 | 7.76:1 | Pass |
| secondary text on a card | dark | -79.8 | 12.19:1 | Pass |
| secondary text on the muted ground | light | 80.4 | 7.05:1 | Pass |
| secondary text on the muted ground | dark | -77.8 | 10.28:1 | Pass |
| a hairline boundary on the page | light | 19.3 | 1.41:1 | Below floor |
| a hairline boundary on the page | dark | -8.2 | 1.90:1 | Below floor |
| a hairline boundary on a card | light | 22.4 | 1.47:1 | Below floor |
| a hairline boundary on a card | dark | -7.3 | 1.72:1 | Below floor |
| a hairline boundary on the muted ground | light | 15.8 | 1.34:1 | Below floor |
| a hairline boundary on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on the page | light | 69.8 | 4.52:1 | Pass |
| a placeholder boundary on the page | dark | -51.0 | 7.62:1 | Pass |
| a placeholder boundary on a card | light | 72.8 | 4.72:1 | Pass |
| a placeholder boundary on a card | dark | -50.1 | 6.91:1 | Pass |
| a placeholder boundary on the muted ground | light | 66.2 | 4.29:1 | Pass |
| a placeholder boundary on the muted ground | dark | -48.1 | 5.83:1 | Pass |
| a placeholder fill on the page | light | 8.3 | 1.18:1 | Below floor |
| a placeholder fill on the page | dark | -8.2 | 1.90:1 | Below floor |
| a placeholder fill on a card | light | 11.4 | 1.23:1 | Below floor |
| a placeholder fill on a card | dark | -7.3 | 1.72:1 | Below floor |
| a placeholder fill on the muted ground | light | 0.0 | 1.12:1 | Below floor |
| a placeholder fill on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on its own fill | light | 58.9 | 3.84:1 | Pass |
| a placeholder boundary on its own fill | dark | -41.5 | 4.01:1 | Below floor |
| the card hairline on the page | light | 69.8 | 4.52:1 | Pass |
| the card hairline on the page | dark | -28.7 | 4.19:1 | Below floor |
| the card hairline on a card | light | 72.8 | 4.72:1 | Pass |
| the card hairline on a card | dark | -27.9 | 3.80:1 | Below floor |
These are the measured token pairs this component draws colour from, not a measurement of the component itself.
Data attributes
| Attribute | Condition | Value |
|---|---|---|
| data-slot | On the root and every inner part, always | questionnaire, questionnaire-title, questionnaire-progress, questionnaire-question, questionnaire-prompt, questionnaire-control |
| data-question-index | On every question list item, always | The 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.
Related
- 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.
DoseTracker
A record of the medicine doses a product has already logged, drawing each as a fact with a word and a shape, never computing adherence or judging a missed dose.
SymptomPicker
A multi-select over a symptom list the product supplies, recording which options a reader ticks and never checking, triaging or interpreting them.