Fintech & Payments

Fraud Score Gauge

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.

Install

npx shadcn@latest add @paragon/fraud-score-gauge

Also installs: number-ticker

fraud-score-gauge.tsx

"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>
  );
}