Data display

Score bar

Labelled meter for a 0-100 score.

Preview
Live demo coming - the component is in the registry below.
Install
npx shadcn@latest add https://ui.thjsecurity.tech/r/score-bar.json

Pulls the component into components/ui/score-bar.tsx and its token dependencies. The app owns the copy from then on.

Source
import * as React from "react"

import { cn } from "@/lib/utils"

interface ScoreBarProps {
  /** 0-100. */
  score: number
  /**
   * When true the metric is "lower is better" (e.g. ASM risk score):
   * a low score sits on the green end, a high score on the red end.
   */
  inverse?: boolean
  /** Caption under the number. */
  label?: React.ReactNode
  className?: string
}

type Tone = "good" | "warn" | "bad"

function ratingFor(score: number, inverse: boolean): { text: string; tone: Tone } {
  // Normalize so `good` is always the high-quality end.
  // Default: higher score = worse (a risk meter). `inverse` flips it so a
  // higher score is better (a quality/security score).
  const good = inverse ? score : 100 - score
  if (good >= 80) return { text: "Excellent", tone: "good" }
  if (good >= 60) return { text: "Good", tone: "good" }
  if (good >= 40) return { text: "Fair", tone: "warn" }
  if (good >= 20) return { text: "Poor", tone: "warn" }
  return { text: "Critical", tone: "bad" }
}

const TONE_PILL: Record<Tone, string> = {
  good: "bg-sev-low/15 text-sev-low",
  warn: "bg-sev-medium/15 text-sev-medium",
  bad: "bg-sev-critical/15 text-sev-critical",
}

/**
 * Score meter: a large number, a qualitative pill, and a green→amber→red
 * gradient track with a dot marker. By default higher = worse (risk), so the
 * marker moves right as the score rises. Pure CSS - no chart library.
 */
function ScoreBar({ score, inverse, label, className }: ScoreBarProps) {
  const clamped = Math.max(0, Math.min(100, score))
  // Marker tracks the raw score: low → left (safe), high → right (critical).
  // `inverse` flips it for "higher is better" metrics.
  const pos = inverse ? 100 - clamped : clamped
  const rating = ratingFor(clamped, Boolean(inverse))

  return (
    <div className={cn("space-y-3", className)}>
      <div className="flex items-end gap-3">
        <span className="text-3xl font-bold leading-none tabular-nums">
          {Number.isInteger(clamped) ? clamped : clamped.toFixed(1)}
        </span>
        <span
          className={cn(
            "mb-0.5 rounded-full px-2 py-0.5 text-xs font-semibold",
            TONE_PILL[rating.tone]
          )}
        >
          {rating.text}
        </span>
      </div>
      <div className="relative">
        <div
          className="h-2 w-full rounded-full"
          style={{
            background:
              "linear-gradient(90deg, hsl(var(--sev-low)), hsl(var(--sev-medium)), hsl(var(--sev-critical)))",
          }}
        />
        <div
          className="absolute top-1/2 h-3.5 w-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-background bg-foreground shadow-card"
          style={{ left: `${pos}%` }}
        />
      </div>
      {label && (
        <p className="text-sm text-muted-foreground">{label}</p>
      )}
    </div>
  )
}

export { ScoreBar }