Score bar
Labelled meter for a 0-100 score.
npx shadcn@latest add https://ui.thjsecurity.tech/r/score-bar.jsonPulls the component into components/ui/score-bar.tsx and its token dependencies. The app owns the copy from then on.
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 }