Score ring
A score in a ring, coloured by how much of its scale it fills.
Server-compatible inline SVG: the ring is one circle drawn with stroke-dasharray and stroke-dashoffset, rotated so it fills from twelve o'clock. scoreTone(value, max) is exported. The root is the meter and the drawing is hidden from screen readers; a label that is not plain text cannot name it, so the scale stands in as the name until you pass aria-label.
Install
$
npx shadcn@latest add @vibra/score-ringNeeds the @vibra registry in your components.json — set it up once.
Examples
import { ScoreRing } from "@/components/ui/score-ring"
// Health scores out of 100. The ring reads success from 80, warning from 50,
// and danger below that, so the three accounts land on three tones.
const ACCOUNTS = [
{ name: "Lumen Studio", score: 92 },
{ name: "Beacon Retail", score: 64 },
{ name: "Alder Robotics", score: 38 },
]
export default function ScoreRingDemo() {
return (
<div className="flex w-full max-w-sm items-start justify-between gap-4 panel p-6">
{ACCOUNTS.map((account) => (
<ScoreRing key={account.name} value={account.score} label={account.name} size={92} />
))}
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value | number | — | The score. |
| max | number | 100 | The score it is out of. |
| size | number | 112 | Diameter in pixels; the stroke and the type scale from it. |
| strokeWidth | number | 8 | Thickness of the track and the ring. |
| label | React.ReactNode | — | Sits under the ring, and names the meter when it is plain text. |
| tone | "default" | "success" | "warning" | "danger" | "info" | "auto" | "auto" | auto is success from 80 percent of the scale, warning from 50, danger below. |
| format | ((v: number) => string) | (Intl.NumberFormatOptions & { locale?: string }) | whole units with separators | Formats the number in the middle of the ring: a function, or the options of an Intl.NumberFormat — plain data, which a server component can pass across to the ring, e.g. { style: "unit", unit: "percent" }. |
Dependencies
Registry
Source
import * as React from "react"
import { cva } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { clamp, formatNumber, numberFormatter, percentOf, type NumberFormat } from "@/lib/format"
type Tone = "default" | "success" | "warning" | "danger" | "info"
/** The tone an auto score ring takes: success from 80% of the scale, warning from 50%, danger below that. */
export function scoreTone(value: number, max: number): Tone {
const percent = percentOf(value, max)
if (percent >= 80) return "success"
if (percent >= 50) return "warning"
return "danger"
}
// The ring paints with `stroke="currentColor"`, so the tone only ever sets a
// text colour and never a hard-coded stroke.
const scoreRingArcVariants = cva("", {
variants: {
tone: {
default: "text-primary",
success: "text-success",
warning: "text-warning",
danger: "text-danger",
info: "text-info",
},
},
defaultVariants: { tone: "default" },
})
const DEFAULT_FORMAT = (value: number) => formatNumber(value, { maximumFractionDigits: 0 })
export type ScoreRingProps = React.ComponentProps<"div"> & {
value: number
max?: number
/** Diameter in pixels; the stroke and the type scale from it. */
size?: number
strokeWidth?: number
/** Sits under the ring, and names the meter when it is plain text. */
label?: React.ReactNode
/** "auto" reads the tone off the share of the scale. */
tone?: Tone | "auto"
/**
* Formats the reading and the scale: a function, or the options of an
* Intl.NumberFormat — plain data, which a server component can pass, e.g.
* `{ style: "unit", unit: "percent" }`.
*/
format?: NumberFormat
}
function ScoreRing({
className,
value,
max = 100,
size = 112,
strokeWidth = 8,
label,
tone = "auto",
format: formatOption = DEFAULT_FORMAT,
"aria-label": ariaLabel,
...props
}: ScoreRingProps) {
const resolvedTone = tone === "auto" ? scoreTone(value, max) : tone
const center = size / 2
const radius = (size - strokeWidth) / 2
const circumference = 2 * Math.PI * radius
const fraction = clamp(percentOf(value, max) / 100, 0, 1)
const format = numberFormatter(formatOption)
const reading = format(value)
return (
<div
data-slot="score-ring"
data-tone={resolvedTone}
role="meter"
// The ring is the meter itself, so its own name and value carry the
// reading. A ReactNode label cannot become a name, so the scale stands in
// rather than leaving the meter unnamed — pass `aria-label` to say what
// is being scored.
aria-label={
ariaLabel ?? (typeof label === "string" ? label : `Score out of ${format(max)}`)
}
aria-valuenow={clamp(value, 0, max)}
aria-valuemin={0}
aria-valuemax={max}
aria-valuetext={reading}
className={cn("inline-flex flex-col items-center gap-1.5", className)}
{...props}
>
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} aria-hidden="true">
{/* Rotated so the ring fills from twelve o'clock instead of three. The
value sits outside the rotation, upright. */}
<g data-slot="score-ring-rotation" transform={`rotate(-90 ${center} ${center})`}>
<circle
data-slot="score-ring-track"
cx={center}
cy={center}
r={radius}
fill="none"
strokeWidth={strokeWidth}
className="stroke-muted"
/>
<circle
data-slot="score-ring-arc"
cx={center}
cy={center}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={circumference * (1 - fraction)}
className={scoreRingArcVariants({ tone: resolvedTone })}
/>
</g>
<text
data-slot="score-ring-value"
x={center}
y={center}
textAnchor="middle"
dominantBaseline="central"
fontSize={Math.round(size * 0.28)}
className="fill-foreground font-semibold tabular-nums"
>
{reading}
</text>
</svg>
{label ? (
<div data-slot="score-ring-label" className="text-sm text-muted-foreground">
{label}
</div>
) : null}
</div>
)
}
export { ScoreRing, scoreRingArcVariants }