---
title: "ScaleInput"
description: "A rating scale drawn from the points and end words the product supplies, for a self-report the product reads, shipping no instrument of its own."
url: "https://opsinjs.pensievelabs.org/components/scale-input"
source: "https://opsinjs.pensievelabs.org/components/scale-input.md"
section: "Components"
status: "shipped"
kind: "component"
category: "health-input"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["pain scale", "rating scale", "likert"]
governedBy: ["clinical-interaction-guidelines", "numbers-units-precision", "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="scale-input"
  status="shipped"
  questions="[
  &#x22;No contrast pair has been measured, and the scale sets no surface of its own, so points are painted on the host's ground.&#x22;,
  &#x22;Nobody has heard this scale in a screen reader, so whether the folded end words read as a scale is unknown.&#x22;,
  &#x22;No named reviewer has signed this page off, so read every claim here as unattributed.&#x22;,
]"
>
  Audited against WCAG 2.2 AA. Clinical review pending.
</StubNotice>

## Preview [#preview]

<ComponentPreview name="scale-input" />

## Installation [#installation]

<ComponentInstall name="scale-input" unbuilt="false" importPath="@/components/ui/scale-input" dependencies="[&#x22;@base-ui/react&#x22;]" />

## Usage [#usage]

```tsx
import { ScaleInput } from "@/components/ui/scale-input"
```

```tsx
<ScaleInput
  label="Comfort right now"
  points={10}
  value={rating}
  onValueChange={setRating}
  minLabel="Not at all"
  maxLabel="Completely"
/>
```

## When to use it [#when-to-use-it]

<WhenToUse
  use="[
  &#x22;One self-report on a scale the product's instrument defines, with the points and end words from the product.&#x22;,
  &#x22;A single question asked repeatedly with the same points and end words, so the reader can watch their answer move.&#x22;,
  &#x22;A rating with a low end and a high end the reader places themselves between, rather than unordered choices.&#x22;,
]"
  avoid="[
  { case: &#x22;The answer is free text in the reader's own words, not a point on a scale.&#x22;, instead: &#x22;textarea&#x22; },
  { case: &#x22;The options are a fixed set of labelled choices with no end word and no ordering.&#x22;, instead: &#x22;radio-group&#x22; },
  { case: &#x22;A multi-question instrument where several rated items add up under one heading.&#x22;, instead: &#x22;questionnaire&#x22; },
]"
/>

## Clinical meaning [#clinical-meaning]

**Asserts.** The reader chose this point on a scale the product defined. The value
goes back through `onValueChange`, with no total, band, verdict or
interpretation added.

**Never read as.** A score, a measurement or a result opsinjs derived. A far point is
a high self-report, and whether it matters is a clinical judgement this component does
not make. It never diagnoses, triages or advises. See
[Who this is for](../health/who-this-is-for.mdx).

**Colour axis.** Neither. The scale writes neither `data-status` nor `data-category`,
and the chosen point takes the neutral primary fill. A product that decides a rating
matters says so on its own status surface. See
[The two colour axes](../health/two-colour-axes.mdx).

**Thresholds.** None. The point count arrives through `points` and the end words
through `minLabel` and `maxLabel`. opsinjs ships no scale, no anchor words and no
scoring. See [Clinical interaction guidelines](../health/clinical-interaction-guidelines.mdx)
and [Numbers, units and precision](../health/numbers-units-precision.mdx).

## Anatomy [#anatomy]

<Anatomy
  name="scale-input"
  parts="[
  {
    name: &#x22;ScaleInput&#x22;,
    describes: &#x22;The root, a Base UI RadioGroup with role radiogroup and one roving tab stop, named by label.&#x22;,
    prop: &#x22;label&#x22;,
  },
  {
    name: &#x22;ScaleInput.Track&#x22;,
    describes: &#x22;The row of points. It wraps rather than scrolling sideways and carries no role.&#x22;,
    prop: &#x22;points&#x22;,
  },
  {
    name: &#x22;ScaleInput.Point&#x22;,
    describes: &#x22;One point, a Base UI Radio with role radio and aria-checked, floored at a 44pt target.&#x22;,
    prop: &#x22;value&#x22;,
  },
  {
    name: &#x22;ScaleInput.PointLabel&#x22;,
    describes: &#x22;The point's number: the visible label and most of what a screen reader reads.&#x22;,
  },
  {
    name: &#x22;ScaleInput.Anchor&#x22;,
    describes: &#x22;One end word under the first or last point, folded into that point's accessible name.&#x22;,
    prop: &#x22;minLabel&#x22;,
  },
]"
/>

<CompositionTree
  name="scale-input"
  tree="[
  {
    part: &#x22;ScaleInput&#x22;,
    cardinality: &#x22;1&#x22;,
    children: [
      {
        part: &#x22;ScaleInput.Track&#x22;,
        cardinality: &#x22;1&#x22;,
        children: [
          {
            part: &#x22;ScaleInput.Point&#x22;,
            cardinality: &#x22;2..n&#x22;,
            note: &#x22;One per point, numbered 1 to points.&#x22;,
            children: [
              {
                part: &#x22;ScaleInput.PointLabel&#x22;,
                cardinality: &#x22;1&#x22;,
              },
            ],
          },
        ],
      },
      {
        part: &#x22;ScaleInput.Anchor&#x22;,
        cardinality: &#x22;0..2&#x22;,
        note: &#x22;Only when minLabel or maxLabel is supplied.&#x22;,
      },
    ],
  },
]"
/>

## Examples [#examples]

### A rating with end words [#a-rating-with-end-words]

Ten points with a word at each end, from `label`, `points`, `minLabel` and `maxLabel`.

<ComponentPreview name="scale-input-rating-with-anchors" kind="example" align="start" />

### No answer yet [#no-answer-yet]

`value` is `null`, so the scale draws with no point chosen rather than defaulting to
a middle the reader never picked.

<ComponentPreview name="scale-input-no-answer-yet" kind="example" align="start" />

## Content guidelines [#content-guidelines]

Write `label` as the thing being rated in the reader's words, "Comfort right now"
rather than "1 to 10". It is the accessible name and the only place the question is
stated. Take the end words from the product's instrument, short and opposite, never
a verdict. Keep the point count fixed.

<DoDont>
  <DoDont.Do>
    **`label="Comfort right now"` with end words from the product's instrument.** Named question, fixed ends, so answers compare over time.
  </DoDont.Do>

  <DoDont.Dont>
    **No `label`, invented end words, a point count that drifts between screens.** Nameless radios whose answers cannot be compared.
  </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.

* One tab stop with a roving focus: `role="radiogroup"` on the root, `role="radio"`
  and `aria-checked` on each point, and selection follows focus.
* Every point floors its hit area at `--opsin-target-minimum` in rem, and the row
  wraps rather than shrinking a point below its target.

**Fixed in this pass.** In forced colours the chosen point now repaints to the system
Highlight pair with a Highlight border and `forced-color-adjust-none`, so selection
stays distinct.

**Deferred, and named.** The question reaches the radiogroup only as an `aria-label`,
weaker than a visible label, the recommended enhancement, a decision shared
with combobox. In normal mode the selection carries no glyph, so luminance inversion
and the border carry it without hue. Every contrast pair on the host's ground is
unmeasured, and the report below prints only what has been measured.

<KeyboardTable
  name="scale-input"
  rows="[
  {
    keys: &#x22;Tab&#x22;,
    action: &#x22;Moves focus into the scale, onto the chosen point&#x22;,
    notes: &#x22;One tab stop. Unanswered, the first point takes it; a disabled point never does.&#x22;,
  },
  {
    keys: &#x22;Shift + Tab&#x22;,
    action: &#x22;Moves focus back out of the scale&#x22;,
    notes: &#x22;A single stop in reverse too.&#x22;,
  },
  {
    keys: &#x22;Arrow Right, Arrow Down&#x22;,
    action: &#x22;Moves to the next point and selects it&#x22;,
    notes: &#x22;Selection follows focus. Wraps last to first. Arrow Left and Right swap in right-to-left.&#x22;,
  },
  {
    keys: &#x22;Arrow Left, Arrow Up&#x22;,
    action: &#x22;Moves to the previous point and selects it&#x22;,
    notes: &#x22;Wraps from the first to the last.&#x22;,
  },
  {
    keys: &#x22;Space&#x22;,
    action: &#x22;Selects the focused point&#x22;,
    notes: &#x22;A point is a role=\&#x22;radio\&#x22;, so Space is its activator.&#x22;,
  },
  {
    keys: &#x22;Enter&#x22;,
    action: &#x22;Does nothing&#x22;,
    notes: &#x22;The primitive cancels Enter, so the scale does not submit a form.&#x22;,
  },
  {
    keys: &#x22;Home, End&#x22;,
    action: &#x22;Not bound&#x22;,
    notes: &#x22;The RadioGroup does not enable them, so they keep the browser default.&#x22;,
  },
]"
/>

<ContrastReport component="scale-input" />

## Data attributes [#data-attributes]

<DataAttributesTable
  name="scale-input"
  rows="[
  {
    attribute: &#x22;data-checked, data-unchecked&#x22;,
    condition: &#x22;On each point, stamped by the Base UI Radio&#x22;,
    value: &#x22;One of the two, by whether the point is the chosen one&#x22;,
  },
]"
/>

## API reference [#api-reference]

<PropsTable name="ScaleInputProps" />

`points` has no default, and fewer than two renders nothing with a development
warning. `value` is `number | null`: `null` draws nothing chosen, and a number
outside 1 to `points` does the same and warns. `label` is required: a
radiogroup with no accessible name is a defect the types cannot prevent.

## Related [#related]

* [RadioGroup](./radio-group.mdx) is for a fixed set of labelled choices with no ordered ends.
* [Textarea](./textarea.mdx) is for an answer in the reader's own words.
* [Questionnaire](./questionnaire.mdx) is the name recorded for a multi-question instrument opsinjs does not ship.
* [Field](./field.mdx) is the wrapper each rated item of a multi-question instrument sits in.
