opsinjs
ComponentsHealth input

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

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

Installation

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 checkbox instead.
  • Exactly one option applies and the rest exclude it, so the choice is one-of-many rather than any-of-many.Use radio-group instead.
  • The reader describes something in their own words rather than picking from a list the product wrote.Use textarea instead.

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

  1. SymptomPickerThe root, a Base UI CheckboxGroup rendered as a named group, holding the shared array of ticked values.Controlled by options
  2. SymptomPicker.SearchThe optional filter input, matching labels case-insensitively, kept separate from the selection.Controlled by searchable
  3. SymptomPicker.OptionOne option, a Base UI checkbox and the whole pressable row, floored at the 44pt target minimum.Controlled by value
  4. SymptomPicker.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.

Theme
Density
Text
Status
symptom-picker-choosing-from-a-list · base base · style base-lyraOpen under the product theme

Filtering 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.

Theme
Density
Text
Status
symptom-picker-with-search · base base · style base-lyraOpen under the product theme

Content 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.

KeyActionNotes
TabMoves to the next control, the filter field or an optionEach checkbox is its own tab stop. A disabled option is skipped.
Shift+TabMoves to the previous controlThe same stops in reverse, then out of the picker.
SpaceTicks or unticks the focused optionAn option is a checkbox, so Space activates it. Focus stays put.
EnterDoes nothing on an optionThe primitive cancels it, so a picker never submits its form.
TypingFilters the list, in the filter field onlyPresent only when searchable is set. It changes no ticked value.
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, alwayssymptom-picker, symptom-picker-search, symptom-picker-option, symptom-picker-option-label, symptom-picker-empty
data-checked, data-uncheckedOn an option, one or the other, by whether it is tickedStamped by Base UI's Checkbox primitive
data-disabledOn an option the product marked disabledThe 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.

  • 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.

On this page