Healthcare

Symptom Scale

A 0–10 gradient symptom slider whose descriptor label and color crossfade as the value moves across bands; keyboard-operable and screen-reader labelled.

Install

npx shadcn@latest add @paragon/symptom-scale

symptom-scale.tsx

"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

export interface SymptomLevel {
  /** Inclusive lower bound this label/tone applies from. */
  from: number;
  label: string;
  /** CSS color for the value readout and marker at this level. */
  color: string;
}

export interface SymptomScaleProps
  extends Omit<
    React.ComponentProps<"div">,
    "onChange" | "defaultValue" | "children"
  > {
  /** Controlled value, 0–max. */
  value?: number;
  /** Uncontrolled initial value. */
  defaultValue?: number;
  max?: number;
  onValueChange?: (value: number) => void;
  /** Question or prompt shown above the track. */
  label?: string;
  /** Level bands, ascending by `from`. */
  levels?: SymptomLevel[];
  static?: boolean;
}

const DEFAULT_LEVELS: SymptomLevel[] = [
  { from: 0, label: "No pain", color: "var(--color-success)" },
  { from: 1, label: "Mild", color: "oklch(0.72 0.15 130)" },
  { from: 4, label: "Moderate", color: "var(--color-warning)" },
  { from: 7, label: "Severe", color: "oklch(0.68 0.19 40)" },
  { from: 9, label: "Worst possible", color: "var(--color-destructive)" },
];

function levelFor(value: number, levels: SymptomLevel[]): SymptomLevel {
  let current = levels[0];
  for (const l of levels) if (value >= l.from) current = l;
  return current;
}

/**
 * A 0–10 symptom scale on a native range input. The value readout label and
 * color crossfade as the value moves across bands, and the thumb tints to the
 * active band color. Fully keyboard-operable (arrows / Home / End) and
 * labelled for screen readers via aria-valuetext. The gradient track and tint
 * transitions are the only motion; reduced motion keeps color, drops the fade
 * (transitions are short and non-looping).
 */
export function SymptomScale({
  value: controlled,
  defaultValue = 0,
  max = 10,
  onValueChange,
  label = "Rate your pain right now",
  levels = DEFAULT_LEVELS,
  static: isStatic = false,
  className,
  id,
  ...props
}: SymptomScaleProps) {
  const reactId = React.useId();
  const fieldId = id ?? reactId;
  const [uncontrolled, setUncontrolled] = React.useState(defaultValue);
  const isControlled = controlled !== undefined;
  const value = isControlled ? controlled : uncontrolled;

  const setValue = (next: number) => {
    if (!isControlled) setUncontrolled(next);
    onValueChange?.(next);
  };

  const level = levelFor(value, levels);
  const pct = (value / max) * 100;

  return (
    <div
      data-slot="symptom-scale"
      className={cn(
        "flex w-full max-w-sm flex-col gap-3 rounded-xl bg-card p-4 shadow-border",
        className,
      )}
      {...props}
    >
      <div className="flex items-baseline justify-between gap-3">
        <label htmlFor={fieldId} className="text-sm font-medium text-foreground">
          {label}
        </label>
        <span className="flex items-baseline gap-1.5">
          <span
            className="text-lg font-semibold tabular-nums"
            style={{
              color: level.color,
              transition: isStatic ? undefined : "color 200ms var(--ease-out)",
            }}
          >
            {value}
          </span>
          {/* Crossfade the descriptor by stacking labels and fading opacity. */}
          <span className="relative inline-grid text-xs">
            {levels.map((l) => (
              <span
                key={l.label}
                aria-hidden={l.label !== level.label}
                className="whitespace-nowrap [grid-area:1/1] text-right"
                style={{
                  color: level.color,
                  opacity: l.label === level.label ? 1 : 0,
                  transition: isStatic
                    ? undefined
                    : "opacity 180ms var(--ease-out)",
                }}
              >
                {l.label}
              </span>
            ))}
            {/* Invisible sizer = the longest label, so width never jumps. */}
            <span
              aria-hidden
              className="invisible whitespace-nowrap [grid-area:1/1]"
            >
              {levels.reduce(
                (a, b) => (b.label.length > a.length ? b.label : a),
                "",
              )}
            </span>
          </span>
        </span>
      </div>

      <div className="group/track relative">
        {/* gradient track */}
        <div
          aria-hidden
          className="pointer-events-none absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full"
          style={{
            background:
              "linear-gradient(to right, var(--color-success), oklch(0.72 0.15 130), var(--color-warning), oklch(0.68 0.19 40), var(--color-destructive))",
            opacity: 0.85,
          }}
        />
        {/* active-value marker tint */}
        <div
          aria-hidden
          className="pointer-events-none absolute top-1/2 size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-card shadow-border ring-ring ring-offset-2 ring-offset-background group-has-[:focus-visible]/track:ring-2"
          style={{
            left: `${pct}%`,
            background: level.color,
            transition: isStatic
              ? undefined
              : "left 120ms var(--ease-out), background-color 200ms var(--ease-out)",
          }}
        />
        <input
          id={fieldId}
          type="range"
          min={0}
          max={max}
          step={1}
          value={value}
          onChange={(e) => setValue(Number(e.target.value))}
          aria-valuetext={`${value} of ${max}, ${level.label}`}
          className="relative h-5 w-full cursor-pointer appearance-none bg-transparent focus-visible:outline-none [&::-moz-range-thumb]:size-5 [&::-moz-range-thumb]:cursor-pointer [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-transparent [&::-webkit-slider-thumb]:size-5 [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-transparent"
        />
      </div>

      <div
        aria-hidden
        className="flex justify-between text-[11px] text-muted-foreground tabular-nums"
      >
        <span>0</span>
        <span>{max}</span>
      </div>
    </div>
  );
}