A 0–10 gradient symptom slider whose descriptor label and color crossfade as the value moves across bands; keyboard-operable and screen-reader labelled.
npx shadcn@latest add @paragon/symptom-scale"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>
);
}