A 0–100 risk gauge whose needle sweeps to the score and whose fill shifts across low/elevated/high bands, with a weighted signal breakdown below.
npx shadcn@latest add @paragon/fraud-score-gaugeAlso installs: number-ticker
"use client";
import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
import { NumberTicker } from "@/registry/paragon/ui/number-ticker";
export interface FraudSignal {
label: string;
/** Contribution to risk, 0–100 (higher = worse). */
weight: number;
detail?: string;
}
export interface FraudScoreGaugeProps extends React.ComponentProps<"div"> {
/** Overall risk score 0–100. */
score: number;
signals?: FraudSignal[];
static?: boolean;
}
const START = -120;
const SWEEP = 240;
const R = 56;
function polar(cx: number, cy: number, r: number, deg: number) {
const rad = ((deg - 90) * Math.PI) / 180;
return [cx + r * Math.cos(rad), cy + r * Math.sin(rad)] as const;
}
function arcPath(cx: number, cy: number, r: number, from: number, to: number) {
const [sx, sy] = polar(cx, cy, r, from);
const [ex, ey] = polar(cx, cy, r, to);
const large = to - from > 180 ? 1 : 0;
return `M ${sx} ${sy} A ${r} ${r} 0 ${large} 1 ${ex} ${ey}`;
}
function band(score: number) {
if (score >= 70)
return { label: "High risk", color: "var(--color-destructive)", cls: "text-destructive" };
if (score >= 40)
return { label: "Elevated", color: "var(--color-warning)", cls: "text-warning" };
return { label: "Low risk", color: "var(--color-success)", cls: "text-success" };
}
/**
* A 0–100 fraud risk gauge: a 240° arc whose needle sweeps to the score on
* first view and whose fill and label shift color across low / elevated /
* high bands. The number counts up beneath it, and a signal breakdown lists
* the weighted contributors with mini bars. Drive `score` from a control to
* watch the needle and bands react. Reduced motion draws the final position.
*/
export function FraudScoreGauge({
score,
signals = [],
static: isStatic = false,
className,
...props
}: FraudScoreGaugeProps) {
const ref = React.useRef<HTMLDivElement>(null);
const reduced = useReducedMotion();
const inView = useInView(ref, { once: true, margin: "0px 0px -32px 0px" });
const animate = !isStatic && !reduced;
const drawn = !animate || inView;
const clamped = Math.min(100, Math.max(0, score));
const b = band(clamped);
const frac = clamped / 100;
const angle = START + (drawn ? frac : 0) * SWEEP;
const cx = 80;
const cy = 80;
// Full-sweep path length, so the fill can grow via stroke-dashoffset
// (transform/paint-safe) rather than animating the `d` attribute.
const arcLen = (SWEEP / 360) * 2 * Math.PI * R;
const fillLen = (drawn ? frac : 0) * arcLen;
return (
<div
ref={ref}
className={cn(
"w-full max-w-sm rounded-xl bg-card p-5 shadow-border",
className,
)}
{...props}
>
<div className="flex flex-col items-center">
<svg viewBox="0 0 160 120" className="w-full max-w-[220px]">
{/* Track */}
<path
d={arcPath(cx, cy, R, START, START + SWEEP)}
fill="none"
stroke="currentColor"
strokeOpacity={0.1}
strokeWidth={12}
strokeLinecap="round"
/>
{/* Filled portion — grows via stroke-dashoffset over the full arc */}
<path
d={arcPath(cx, cy, R, START, START + SWEEP)}
fill="none"
stroke={b.color}
strokeWidth={12}
strokeLinecap="round"
strokeDasharray={`${fillLen} ${arcLen}`}
style={{
transition: animate
? "stroke-dasharray 700ms var(--ease-out), stroke 300ms var(--ease-out)"
: "stroke 300ms var(--ease-out)",
}}
/>
{/* Needle */}
<g
style={{
transformOrigin: `${cx}px ${cy}px`,
transform: `rotate(${angle}deg)`,
transition: animate ? "transform 700ms var(--ease-out)" : undefined,
}}
>
<line
x1={cx}
y1={cy}
x2={cx}
y2={cy - R + 4}
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
/>
</g>
<circle cx={cx} cy={cy} r={4} fill="currentColor" />
</svg>
<div className="-mt-4 flex flex-col items-center">
<span className="text-3xl font-semibold tabular-nums">
<NumberTicker value={clamped} static={!animate} duration={0.6} />
</span>
<span className={cn("text-[13px] font-medium", b.cls)}>{b.label}</span>
</div>
</div>
{signals.length > 0 && (
<ul className="mt-4 flex flex-col gap-2.5 border-t pt-4">
{signals.map((s, i) => (
<li key={s.label} className="flex items-center gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<span className="truncate text-[13px] font-medium">
{s.label}
</span>
<span className="shrink-0 text-[12px] tabular-nums text-muted-foreground">
+{s.weight}
</span>
</div>
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-muted">
<div
className="h-full origin-left rounded-full transition-transform duration-500 ease-out motion-reduce:transition-none"
style={{
background: band(s.weight).color,
transform: `scaleX(${drawn ? Math.min(1, s.weight / 100) : 0})`,
transitionDelay: animate ? `${200 + i * 60}ms` : undefined,
}}
aria-hidden
/>
</div>
{s.detail && (
<p className="mt-0.5 text-[12px] text-muted-foreground">
{s.detail}
</p>
)}
</div>
</li>
))}
</ul>
)}
</div>
);
}