---
title: "SymptomPicker"
description: "A multi-select over a symptom list the product supplies, recording which options a reader ticks and never checking, triaging or interpreting them."
url: "https://opsinjs.pensievelabs.org/components/symptom-picker"
source: "https://opsinjs.pensievelabs.org/components/symptom-picker.md"
section: "Components"
status: "shipped"
kind: "component"
category: "health-input"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["symptoms", "how are you feeling", "symptom checker"]
governedBy: ["clinical-interaction-guidelines", "regulatory-context", "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="symptom-picker"
  status="shipped"
  questions="[
  &#x22;No contrast pair has been measured. This component sets no surface of its own, so the box fill, the check glyph and the field border sit on the host's ground.&#x22;,
  &#x22;Nothing stops a product wiring the returned values into a checker or a triage flow, and no lint rule on a consumer's own code holds that line.&#x22;,
  &#x22;Nobody has listened to this in a screen reader, so whether the filter's changing result count is announced as rows appear and disappear is unheard.&#x22;,
]"
>
  Audited against WCAG 2.2 AA. Clinical review pending.
</StubNotice>

## Preview [#preview]

<ComponentPreview name="symptom-picker" />

## Installation [#installation]

<ComponentInstall name="symptom-picker" unbuilt="false" importPath="@/components/ui/symptom-picker" dependencies="[&#x22;@base-ui/react&#x22;, &#x22;lucide-react&#x22;]" />

## Usage [#usage]

```tsx
import { SymptomPicker } from "@/components/ui/symptom-picker"
```

```tsx
<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 [#when-to-use-it]

<WhenToUse
  use="[
  &#x22;Recording which items from a product-owned list apply, where more than one can apply and the caller wants the set back.&#x22;,
  &#x22;A short list the reader scans in place before a consultation, stored against the reader's own record.&#x22;,
  &#x22;A longer product-owned list, with searchable on so the reader filters the rows by typing rather than scrolling.&#x22;,
]"
  avoid="[
  { case: &#x22;There is one thing to confirm, a single yes or no. A whole picker for one option is read twice.&#x22;, instead: &#x22;checkbox&#x22; },
  { case: &#x22;Exactly one option applies and the rest exclude it, so the choice is one-of-many rather than any-of-many.&#x22;, instead: &#x22;radio-group&#x22; },
  { case: &#x22;The reader describes something in their own words rather than picking from a list the product wrote.&#x22;, instead: &#x22;textarea&#x22; },
]"
/>

## Clinical meaning [#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](../health/regulatory-context.mdx)
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](../health/clinical-interaction-guidelines.mdx) is why the picker
records and never interprets.

## Anatomy [#anatomy]

<Anatomy
  name="symptom-picker"
  parts="[
  {
    name: &#x22;SymptomPicker&#x22;,
    describes: &#x22;The root, a Base UI CheckboxGroup rendered as a named group, holding the shared array of ticked values.&#x22;,
    prop: &#x22;options&#x22;,
  },
  {
    name: &#x22;SymptomPicker.Search&#x22;,
    describes: &#x22;The optional filter input, matching labels case-insensitively, kept separate from the selection.&#x22;,
    prop: &#x22;searchable&#x22;,
  },
  {
    name: &#x22;SymptomPicker.Option&#x22;,
    describes: &#x22;One option, a Base UI checkbox and the whole pressable row, floored at the 44pt target minimum.&#x22;,
    prop: &#x22;value&#x22;,
  },
  {
    name: &#x22;SymptomPicker.OptionLabel&#x22;,
    describes: &#x22;The option's words, the visible label and the accessible name a screen reader reads.&#x22;,
  },
]"
/>

<CompositionTree
  name="symptom-picker"
  tree="[
  {
    part: &#x22;SymptomPicker&#x22;,
    cardinality: &#x22;1&#x22;,
    note: &#x22;role=\&#x22;group\&#x22;, named by label.&#x22;,
    children: [
      {
        part: &#x22;SymptomPicker.Search&#x22;,
        cardinality: &#x22;0..1&#x22;,
        note: &#x22;Present only when searchable is set.&#x22;,
      },
      {
        part: &#x22;SymptomPicker.Option&#x22;,
        cardinality: &#x22;1..n&#x22;,
        note: &#x22;One per visible option.&#x22;,
        children: [
          {
            part: &#x22;SymptomPicker.OptionLabel&#x22;,
            cardinality: &#x22;1&#x22;,
            note: &#x22;The option's accessible name.&#x22;,
          },
        ],
      },
    ],
  },
]"
/>

## Examples [#examples]

### Choosing from a list [#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.

<ComponentPreview name="symptom-picker-choosing-from-a-list" kind="example" align="start" />

### Filtering a long list [#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.

<ComponentPreview name="symptom-picker-with-search" kind="example" align="start" />

## Content guidelines [#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](../health/who-this-is-for.mdx) sets the register.

<DoDont>
  <DoDont.Do>
    **An option names one thing the reader recognises and ticks**, under a group label that asks a question.
  </DoDont.Do>

  <DoDont.Dont>
    **An option that smuggles a severity or a conclusion into its label**, dressing an interpretation as a plain choice.
  </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 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.

<KeyboardTable
  name="symptom-picker"
  rows="[
  {
    keys: &#x22;Tab&#x22;,
    action: &#x22;Moves to the next control, the filter field or an option&#x22;,
    notes: &#x22;Each checkbox is its own tab stop. A disabled option is skipped.&#x22;,
  },
  {
    keys: &#x22;Shift + Tab&#x22;,
    action: &#x22;Moves to the previous control&#x22;,
    notes: &#x22;The same stops in reverse, then out of the picker.&#x22;,
  },
  {
    keys: &#x22;Space&#x22;,
    action: &#x22;Ticks or unticks the focused option&#x22;,
    notes: &#x22;An option is a checkbox, so Space activates it. Focus stays put.&#x22;,
  },
  {
    keys: &#x22;Enter&#x22;,
    action: &#x22;Does nothing on an option&#x22;,
    notes: &#x22;The primitive cancels it, so a picker never submits its form.&#x22;,
  },
  {
    keys: &#x22;Typing&#x22;,
    action: &#x22;Filters the list, in the filter field only&#x22;,
    notes: &#x22;Present only when searchable is set. It changes no ticked value.&#x22;,
  },
]"
/>

<ContrastReport component="symptom-picker" />

## Data attributes [#data-attributes]

<DataAttributesTable
  name="symptom-picker"
  rows="[
  {
    attribute: &#x22;data-slot&#x22;,
    condition: &#x22;On the root and every inner part, always&#x22;,
    value: &#x22;symptom-picker, symptom-picker-search, symptom-picker-option, symptom-picker-option-label, symptom-picker-empty&#x22;,
  },
  {
    attribute: &#x22;data-checked, data-unchecked&#x22;,
    condition: &#x22;On an option, one or the other, by whether it is ticked&#x22;,
    value: &#x22;Stamped by Base UI's Checkbox primitive&#x22;,
  },
  {
    attribute: &#x22;data-disabled&#x22;,
    condition: &#x22;On an option the product marked disabled&#x22;,
    value: &#x22;The row drops to the muted ink and takes no pointer or key&#x22;,
  },
]"
/>

## API reference [#api-reference]

<PropsTable name="SymptomPickerProps" />

`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 [#related]

* [Checkbox](./checkbox.mdx) is one box for a single option to tick, where a whole picker would be read twice.
* [RadioGroup](./radio-group.mdx) is the one-of-many choice, where the options exclude each other.
* [Textarea](./textarea.mdx) is where the reader describes something in their own words rather than choosing.
