Rating Scale
A one-question rating of rising bars that fill on hover, with a plain word for every step.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/rating-scale.json'use client'
import { useId, useState, type KeyboardEvent } from 'react'
import './rating-scale.css'
export interface RatingScaleProps {
question: string
/** One word per step, lowest first. Its length sets the number of steps. */
labels?: string[]
value?: number | null
defaultValue?: number | null
onValueChange?: (value: number) => void
shape?: 'bars' | 'dots'
/** Preview a step as if hovered (for demos). */
preview?: number | null
fill?: 'dark' | 'light'
className?: string
}
const DEFAULT_LABELS = ['Rough', 'Meh', 'Okay', 'Good', 'Great']
/** A one-question rating: rising bars that fill on hover, with a word for each step. */
export function RatingScale({ question, labels = DEFAULT_LABELS, value, defaultValue = null, onValueChange, shape = 'bars', preview, fill = 'dark', className }: RatingScaleProps) {
const [own, setOwn] = useState<number | null>(defaultValue)
const [hover, setHover] = useState<number | null>(null)
const id = useId()
const v = value === undefined ? own : value
const n = labels.length
const shown = hover ?? preview ?? v
const set = (k: number) => {
if (value === undefined) setOwn(k)
onValueChange?.(k)
}
const onKey = (e: KeyboardEvent) => {
const cur = v ?? 0
const next = ['ArrowRight', 'ArrowUp'].includes(e.key) ? Math.min(n, cur + 1) : ['ArrowLeft', 'ArrowDown'].includes(e.key) ? Math.max(1, cur - 1) : e.key === 'Home' ? 1 : e.key === 'End' ? n : 0
if (!next) return
e.preventDefault()
set(next)
document.getElementById(`${id}-${next}`)?.focus()
}
return (
<div className={['sd-rs', className].filter(Boolean).join(' ')} data-fill={fill} data-shape={shape}>
<p className="sd-rs__q" id={`${id}-q`}>
{question}
</p>
<div className="sd-rs__row" role="radiogroup" aria-labelledby={`${id}-q`} onKeyDown={onKey} onPointerLeave={() => setHover(null)}>
{labels.map((l, i) => {
const k = i + 1
return (
<button
key={l}
id={`${id}-${k}`}
type="button"
role="radio"
aria-checked={v === k}
aria-label={`${k} of ${n}, ${l}`}
tabIndex={(v ?? 1) === k ? 0 : -1}
className="sd-rs__step"
data-on={(shown ?? 0) >= k || undefined}
onPointerEnter={() => setHover(k)}
onClick={() => set(k)}
>
<span className="sd-rs__bar" style={{ ['--h' as string]: `${30 + (70 * i) / Math.max(1, n - 1)}%`, transitionDelay: `${i * 30}ms` }} />
</button>
)
})}
</div>
<p className="sd-rs__word" aria-hidden>
<span key={shown ?? 0}>{shown ? labels[shown - 1] : 'Tap to rate'}</span>
<span className="sd-rs__count">{shown ? `${shown}/${n}` : ''}</span>
</p>
</div>
)
}.sd-rs { --bg: #1d1d1d; --fg: #f7f7f7; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.09); --dim: rgb(255 255 255 / 0.1);
width: min(100%, 280px); padding: 18px; border-radius: 22px; background: var(--bg); color: var(--fg); box-shadow: inset 0 0 0 1px var(--line); }
.sd-rs[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.08); --dim: rgb(0 0 0 / 0.08); }
.sd-rs__q { margin: 0; font-size: 15px; font-weight: 500; letter-spacing: -0.01em; }
.sd-rs__row { display: flex; align-items: flex-end; gap: 8px; height: 84px; margin-top: 16px; }
.sd-rs__step { flex: 1; height: 100%; display: flex; align-items: flex-end; border-radius: 10px; }
.sd-rs__bar { display: block; width: 100%; height: var(--h); border-radius: 9px; background: var(--dim); transition: background-color 220ms, transform 300ms cubic-bezier(0.3, 1.5, 0.4, 1); transform-origin: bottom; }
.sd-rs__step[data-on] .sd-rs__bar { background: var(--fg); }
.sd-rs__step:hover .sd-rs__bar { transform: scaleY(1.06); }
.sd-rs__step:active .sd-rs__bar { transform: scaleY(0.94); }
.sd-rs[data-shape='dots'] .sd-rs__row { height: 44px; align-items: center; }
.sd-rs[data-shape='dots'] .sd-rs__step { align-items: center; justify-content: center; }
.sd-rs[data-shape='dots'] .sd-rs__bar { width: 34px; max-width: 100%; height: 34px; border-radius: 999px; transform-origin: center; }
.sd-rs[data-shape='dots'] .sd-rs__step:hover .sd-rs__bar { transform: scale(1.1); }
.sd-rs__word { display: flex; justify-content: space-between; align-items: baseline; margin: 14px 0 0; font-size: 14px; font-weight: 500; }
.sd-rs__word > span:first-child { animation: sd-rs-in 280ms cubic-bezier(0.2, 1, 0.3, 1); }
.sd-rs__count { font-size: 12.5px; font-weight: 400; color: var(--mute); font-feature-settings: 'tnum'; }
.sd-rs button:focus-visible { outline: 2px solid var(--fg); outline-offset: 3px; }
@keyframes sd-rs-in { from { opacity: 0; transform: translateY(4px); } }
@media (prefers-reduced-motion: reduce) { .sd-rs * { animation: none !important; transition: none !important; } }Overview
Bars grow from left to right, so the scale reads as “more” without stars or faces. Hovering fills up to the pointer and the word underneath changes with it.
It is a radiogroup: arrow keys move and select, and each option is announced with its number and word. Three to seven steps, as bars or dots.
When to use
- A single feedback question after a task: a handoff, a support chat, a lesson.
- In-app surveys where stars feel too consumer.
When not to use
- Net Promoter Score (0–10): eleven bars get too thin; use numbered buttons.
- Picking from unordered options.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/rating-scale.json$
npx shadcn@latest add https://sundaeui.dev/r/rating-scale.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/rating-scale.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/rating-scale.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy rating-scale.tsx and rating-scale.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { RatingScale } from '@/components/sundae/rating-scale'
<RatingScale question="How was the handoff?" onValueChange={send} />Props
| Prop | Type | Default |
|---|---|---|
question*The question; also labels the group. | string | — |
labelsOne word per step, lowest first. | string[] | ['Rough','Meh','Okay','Good','Great'] |
value / defaultValueControlled or starting rating (1-based). | number | null | null |
onValueChangeCalled on select. | (value: number) => void | — |
shapeRising bars or a row of dots. | 'bars' | 'dots' | 'bars' |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- role="radiogroup" labelled by the question, with roving focus and aria-checked on each option.
- Each option is named "4 of 5, Good", so the meaning does not depend on bar height.
- Reduced motion removes the fill stagger and bounce.
Customization
- --fg and --dim set filled and empty colours.
- Pass any number of labels between three and seven.
Keyboard
- Tab
- Focus the rating
- ← / →
- Lower or raise the rating
- Home / End
- Lowest or highest
FAQ
- Why bars instead of stars?
- Rising bars show an ordered scale with no cultural baggage and sit well in a monochrome product UI.
- Can people clear their rating?
- Not by default. Add a small “Clear” button that sets value to null if your form needs it.
- Is Rating Scale free?
- Yes. Rating Scale is free for personal and commercial projects, with full source on this page.
- How do I install Rating Scale?
- Run npx shadcn@latest add https://sundaeui.dev/r/rating-scale.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Rating Scale support keyboard and screen readers?
- role="radiogroup" labelled by the question, with roving focus and aria-checked on each option. Each option is named "4 of 5, Good", so the meaning does not depend on bar height.
- Does Rating Scale respect reduced motion?
- Reduced motion removes the fill stagger and bounce.
More like this
All forms →Was This Helpful
Forms · RefinedStar Rating
Forms · RefinedRadio Cards
Forms · RefinedPoll Builder
Forms · RefinedLikert Matrix
Forms · RefinedBug Report
Forms · Refined