SymptomPicker
A multi-select over a symptom list the product supplies, recording which options a reader ticks and never checking, triaging or interpreting them.
Preview
symptom-picker · base base · style base-lyraOpen under the product themeInstallation
pnpm dlx shadcn@latest add @opsinjs/symptom-picker
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 { SymptomPicker } from "@/components/ui/symptom-picker"<SymptomPicker
label="Which of these apply to you right now?"
value={selected}
onValueChange={setSelected}
options={[
{ value: "example-one", label: "Example symptom one" },
{ value: "example-two", label: "Example symptom two" },
{ value: "example-three", label: "Example symptom three" },
]}
/>When to use it
Use it when
- Recording which items from a product-owned list apply, where more than one can apply and the caller wants the set back.
- A short list the reader scans in place before a consultation, stored against the reader's own record.
- A longer product-owned list, with searchable on so the reader filters the rows by typing rather than scrolling.
Do not use it when
- There is one thing to confirm, a single yes or no. A whole picker for one option is read twice.Use
checkboxinstead. - Exactly one option applies and the rest exclude it, so the choice is one-of-many rather than any-of-many.Use
radio-groupinstead. - The reader describes something in their own words rather than picking from a list the product wrote.Use
textareainstead.
Clinical meaning
Asserts. These are the values a reader ticked from the list you supplied. It asserts nothing about what an option means, how the ticked set reads together, or what happens next.
Never read as. Not a symptom checker, not a triage, not a diagnosis. Feeding the returned array into a rule that reaches a clinical conclusion moves the product onto the pathway Regulatory context describes.
Colour axis. Neither. A selection is not a clinical level, so the component
carries no data-status and no data-category. The ticked state travels as a
check shape and a quiet fill, so it survives greyscale.
Thresholds. The consuming product owns them. opsinjs ships no symptom list, no option labels and no default text, because a controlled vocabulary is product specific. Clinical interaction guidelines is why the picker records and never interprets.
Anatomy
SymptomPickerThe root, a Base UI CheckboxGroup rendered as a named group, holding the shared array of ticked values.Controlled byoptionsSymptomPicker.SearchThe optional filter input, matching labels case-insensitively, kept separate from the selection.Controlled bysearchableSymptomPicker.OptionOne option, a Base UI checkbox and the whole pressable row, floored at the 44pt target minimum.Controlled byvalueSymptomPicker.OptionLabelThe option's words, the visible label and the accessible name a screen reader reads.
- SymptomPicker 1role="group", named by label.
- SymptomPicker.Search 0..1Present only when searchable is set.
- SymptomPicker.Option 1..nOne per visible option.
- SymptomPicker.OptionLabel 1The option's accessible name.
Examples
Choosing from a list
The case the picker was built for, driven by options. The labels are fictional
placeholders, because opsinjs ships no symptom list and an example must never be
mistaken for a real vocabulary.
symptom-picker-choosing-from-a-list · base base · style base-lyraOpen under the product themeFiltering a long list
The same picker with searchable on. The filter narrows the visible rows by a
match on their label, and the ticked values stay ticked as it narrows.
symptom-picker-with-search · base base · style base-lyraOpen under the product themeContent guidelines
Write every option in the reader's own words and keep each label short enough to
scan. Phrase label as the question the list answers rather than as a heading.
Never phrase an option as a verdict or a finding. Who this is
for sets the register.
Do
An option names one thing the reader recognises and ticks, under a group label that asks a question.
Don’t
An option that smuggles a severity or a conclusion into its label, dressing an interpretation as a plain choice.
Accessibility
Audited against WCAG 2.2 AA, in a source pass and a rendered pass. The audit is author-run, not an independent review, and clinical review is pending.
What the audit fixed. With searchable on, typing filters the rows and can
empty the list, but that count change sat in no live region, so a screen reader
never heard the result count or that nothing matched (SC 4.1.3 Status Messages). A
persistent visually hidden role="status" region now announces the filtered count.
What the audit confirmed. Each option is a real checkbox with its icon
aria-hidden, the pressable row floors at --opsin-target-minimum in rem, and
the rendered pass, run with axe, target-size and 320px reflow, was clean.
forced-colors stays unchecked.
One residual, deferred. The group and filter names are forwarded only as
aria-label, with no persistent visible label. A required label prop keeps the
group from being nameless, but a visible label is a recommended enhancement, held
for one repair across the whole set.
| Key | Action | Notes |
|---|---|---|
| Tab | Moves to the next control, the filter field or an option | Each checkbox is its own tab stop. A disabled option is skipped. |
| Shift+Tab | Moves to the previous control | The same stops in reverse, then out of the picker. |
| Space | Ticks or unticks the focused option | An option is a checkbox, so Space activates it. Focus stays put. |
| Enter | Does nothing on an option | The primitive cancels it, so a picker never submits its form. |
| Typing | Filters the list, in the filter field only | Present only when searchable is set. It changes no ticked value. |
| 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 | symptom-picker, symptom-picker-search, symptom-picker-option, symptom-picker-option-label, symptom-picker-empty |
| data-checked, data-unchecked | On an option, one or the other, by whether it is ticked | Stamped by Base UI's Checkbox primitive |
| data-disabled | On an option the product marked disabled | The row drops to the muted ink and takes no pointer or key |
API reference
Prop
Type
Generated from SymptomPickerProps in registry/bases/base/symptom-picker.tsx.
options is the product's vocabulary and the whole of what the picker draws, so
an empty array renders nothing and warns in development. value and
onValueChange make this controlled, with no selection state of its own. A value
matching no option is kept and named by a warning. label is required, because a
group with no accessible name is a defect.
Related
- Checkbox is one box for a single option to tick, where a whole picker would be read twice.
- RadioGroup is the one-of-many choice, where the options exclude each other.
- Textarea is where the reader describes something in their own words rather than choosing.
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.
Value
A number and its unit, formatted the same way everywhere. It is rounded honestly, spoken correctly, and never shown to more precision than the measurement has.