Threshold Ring

Compact ring gauge expressing how close a value is to a threshold.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/threshold-ring.json

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

Code

"use client"; import { type ComponentPropsWithoutRef, forwardRef, type ReactNode, } from "react"; import { cn } from "../../lib/utils"; /** * Tone of the active arc — drives its stroke color. * * @public */ export type ThresholdRingTone = "danger" | "neutral" | "success" | "warn"; const TONE_STROKE: Record<ThresholdRingTone, string> = { danger: "stroke-red-500", neutral: "stroke-foreground", success: "stroke-emerald-500", warn: "stroke-amber-500", }; /** * Localizable strings. * * @public */ export type ThresholdRingLabels = { /** Aria-label override. Defaults to `"Threshold ring"`. */ region?: string; }; const DEFAULT_LABELS = { region: "Threshold ring", } as const satisfies Required<ThresholdRingLabels>; /** * Props for {@link ThresholdRing}. * * @public */ export type ThresholdRingProps = { /** Optional center label (formatted by host — e.g. `"82%"`). */ centerLabel?: ReactNode; /** Localizable strings. */ labels?: ThresholdRingLabels; /** Upper bound of the ring's value range. Defaults to `1`. */ max?: number; /** Outer diameter in pixels. Defaults to `64`. */ size?: number; /** Stroke width in pixels. Defaults to `6`. */ stroke?: number; /** Optional threshold marker `0..max` rendered as a small notch. */ threshold?: number; /** Tone of the active arc. Defaults to `"neutral"`. */ tone?: ThresholdRingTone; /** Current value `0..max`. */ value: number; } & ComponentPropsWithoutRef<"svg">; const clamp = (v: number, min: number, max: number): number => { if (v < min) { return min; } if (v > max) { return max; } return v; }; const polar = (input: { angle: number; cx: number; cy: number; r: number; }): { x: number; y: number } => { const rad = (input.angle - 90) * (Math.PI / 180); return { x: input.cx + input.r * Math.cos(rad), y: input.cy + input.r * Math.sin(rad), }; }; type Geometry = { circumference: number; cx: number; cy: number; r: number; ratio: number; size: number; stroke: number; tickAngle: null | number; }; const computeGeometry = (input: { max: number; size: number; stroke: number; threshold?: number; value: number; }): Geometry => { const safeMax = input.max <= 0 ? 1 : input.max; const ratio = clamp(input.value / safeMax, 0, 1); const cx = input.size / 2; const cy = input.size / 2; const r = (input.size - input.stroke) / 2; const circumference = 2 * Math.PI * r; const tickAngle = input.threshold === undefined ? null : clamp(input.threshold / safeMax, 0, 1) * 360; return { circumference, cx, cy, r, ratio, size: input.size, stroke: input.stroke, tickAngle, }; }; const Tick = (props: { geom: Geometry }): null | React.ReactElement => { const { geom } = props; if (geom.tickAngle === null) { return null; } const inner = polar({ angle: geom.tickAngle, cx: geom.cx, cy: geom.cy, r: geom.r - geom.stroke / 2, }); const outer = polar({ angle: geom.tickAngle, cx: geom.cx, cy: geom.cy, r: geom.r + geom.stroke / 2, }); return ( <line className="stroke-foreground/80" data-threshold-ring-tick strokeLinecap="round" strokeWidth={2} x1={inner.x} x2={outer.x} y1={inner.y} y2={outer.y} /> ); }; /** * Compact ring gauge expressing how close a value is to a threshold. * Pure presentation; the host supplies the value, threshold, and tone. * Use to overlay budget / quota / SLA indicators on canvas objects * without consuming card real-estate. * * Distinct from `MetricGauge`: this primitive is small, headless, and * meant to attach to a runtime object — not a dashboard tile. * * @example * ```tsx * <ThresholdRing value={0.82} threshold={0.7} tone="warn" centerLabel="82%" /> * ``` * * @public */ export const ThresholdRing = forwardRef<SVGSVGElement, ThresholdRingProps>( (props, ref) => { const { centerLabel, className, labels, max = 1, size = 64, stroke = 6, threshold, tone = "neutral", value, ...rest } = props; const resolvedLabels = { ...DEFAULT_LABELS, ...labels }; const geom = computeGeometry({ max, size, stroke, threshold, value }); const dash = `${geom.ratio * geom.circumference} ${geom.circumference}`; return ( <svg aria-label={resolvedLabels.region} className={cn("inline-block", className)} data-threshold-ring data-threshold-tone={tone} height={geom.size} ref={ref} role="img" viewBox={`0 0 ${geom.size} ${geom.size}`} width={geom.size} {...rest} > <circle className="stroke-muted" cx={geom.cx} cy={geom.cy} fill="none" r={geom.r} strokeWidth={geom.stroke} /> <circle className={cn("transition-all duration-300", TONE_STROKE[tone])} cx={geom.cx} cy={geom.cy} data-threshold-ring-arc fill="none" r={geom.r} strokeDasharray={dash} strokeLinecap="round" strokeWidth={geom.stroke} transform={`rotate(-90 ${geom.cx} ${geom.cy})`} /> <Tick geom={geom} /> {centerLabel ? ( <text className="fill-foreground text-[10px] font-semibold" data-threshold-ring-label dominantBaseline="central" textAnchor="middle" x={geom.cx} y={geom.cy} > {centerLabel} </text> ) : null} </svg> ); }, ); ThresholdRing.displayName = "ThresholdRing";

Dependencies

  • @vllnt/ui@^0.2.1