Skip to contentVibraUI
Metrics

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-ring

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
valuenumber—The score.
maxnumber100The score it is out of.
sizenumber112Diameter in pixels; the stroke and the type scale from it.
strokeWidthnumber8Thickness of the track and the ring.
labelReact.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 separatorsFormats 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

Source

components/ui/score-ring.tsx
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 }