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
bash

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";
typescript

Dependencies

  • @vllnt/ui@^0.2.1