Game UI

Ring meter

A gauge filling round an icon.

stablev0.2.0 Chromium 103 ui-scale safe

Install

Copies the file into components/ds/, with its registry dependencies and npm packages. See Installation for the one-time setup.
bunx shadcn@3 add @fastrp/ring-meter

Examples

Default

Open alone
$15,150
$700

API

<RingMeter />

A ring that fills clockwise around an icon — GTA VI's money and ability gauges. Drawn in an SVG viewBox, so it is as large as its box: size it with a class (`size-12`).

PropTypeDefaultNotes
value*number—
childrenReactNode—What sits in the ring: an icon, a short figure.
labelstring—
maxnumber100
tone"primary" | "danger" | "success" | "warning" | "pink" | "teal" | "purple" | "cyan"primary

Dependencies

npm

  • None

Registry

  • @fastrp/utils

Source

components/ds/ring-meter.tsxShow
import type * as React from 'react';
import { cn } from '@/lib/utils';

// @fast-ds [email protected]

/** Stroke and text in the tone, so the glow's `currentColor` matches the ring. */
const STROKE = {
  primary: 'stroke-primary text-primary',
  success: 'stroke-success text-success',
  danger: 'stroke-danger text-danger',
  warning: 'stroke-warning text-warning',
  pink: 'stroke-pink text-pink',
  teal: 'stroke-teal text-teal',
  purple: 'stroke-purple text-purple',
  cyan: 'stroke-cyan text-cyan',
} as const;

const RADIUS = 15.5;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;

export type RingMeterProps = Omit<React.ComponentProps<'div'>, 'children'> & {
  value: number;
  max?: number;
  tone?: keyof typeof STROKE;
  /** What sits in the ring: an icon, a short figure. */
  children?: React.ReactNode;
  label?: string;
};

/**
 * A ring that fills clockwise around an icon — GTA VI's money and ability gauges. Drawn in an SVG
 * viewBox, so it is as large as its box: size it with a class (`size-12`).
 */
function RingMeter({
  value,
  max = 100,
  tone = 'primary',
  label,
  children,
  className,
  ...props
}: RingMeterProps) {
  const share = max > 0 ? Math.min(1, Math.max(0, value / max)) : 0;

  return (
    // biome-ignore lint/a11y/useSemanticElements: a native <meter> cannot hold the label and the drawn fill
    <div
      data-slot="ring-meter"
      role="meter"
      aria-valuenow={value}
      aria-valuemin={0}
      aria-valuemax={max}
      aria-label={label}
      className={cn('relative inline-flex size-12 shrink-0 items-center justify-center', className)}
      {...props}
    >
      <svg viewBox="0 0 36 36" aria-hidden className="absolute inset-0 size-full -rotate-90">
        <circle
          cx="18"
          cy="18"
          r={RADIUS}
          className="fill-ink/60 stroke-foreground/15"
          strokeWidth="2.5"
        />
        <circle
          cx="18"
          cy="18"
          r={RADIUS}
          fill="none"
          strokeWidth="2.5"
          strokeLinecap="round"
          strokeDasharray={CIRCUMFERENCE}
          strokeDashoffset={CIRCUMFERENCE * (1 - share)}
          className={cn(
            'transition-[stroke-dashoffset] duration-slow ease-brand [filter:drop-shadow(0_0_0.2rem_currentColor)]',
            STROKE[tone],
          )}
        />
      </svg>
      <span className="relative flex items-center justify-center font-display text-sm text-foreground [&_svg]:size-[45%]">
        {children}
      </span>
    </div>
  );
}

export { RingMeter };