Gauge

The open-ring dial and the linear capacity bar.

SwiftUI: Gauge

72Battery
480Move
23Storage
64 GB used128 GB

Installation

npx shadcn@latest add https://cupertino-ui.baltoon.jp/r/gauge.json

Requires the theme tokens — see Installation if this is your first component.

Source

"use client";

import * as React from "react";

import { cn } from "@/lib/utils";

/**
 * SwiftUI Gauge — `circular` is .gaugeStyle(.accessoryCircular),
 * the watchOS/StatusBar open-ring dial; `linear` is the thin
 * capacity bar.
 */
function Gauge({
  className,
  value,
  min = 0,
  max = 100,
  variant = "circular",
  size = 64,
  label,
  currentValueLabel,
  tint = "var(--system-blue)",
  ...props
}: Omit<React.ComponentProps<"div">, "children"> & {
  value: number;
  min?: number;
  max?: number;
  variant?: "circular" | "linear";
  /** Diameter in px (circular only). */
  size?: number;
  /** Small caption under the dial / beside the bar. */
  label?: React.ReactNode;
  /** Text in the dial center; defaults to the value. */
  currentValueLabel?: React.ReactNode;
  /** Ring/bar color, any CSS color. */
  tint?: string;
}) {
  const fraction = Math.min(1, Math.max(0, (value - min) / (max - min)));

  if (variant === "linear") {
    return (
      <div
        data-slot="gauge"
        role="meter"
        aria-valuenow={value}
        aria-valuemin={min}
        aria-valuemax={max}
        className={cn("flex w-full flex-col gap-1.5", className)}
        {...props}
      >
        <div className="h-[5px] w-full overflow-hidden rounded-full bg-fill">
          <div
            className="h-full rounded-full transition-[width] duration-500 ease-[var(--ease-out-expo)]"
            style={{ width: `${fraction * 100}%`, backgroundColor: tint }}
          />
        </div>
        {label ? (
          <div className="flex justify-between text-caption-1 text-secondary-label">
            {label}
          </div>
        ) : null}
      </div>
    );
  }

  // Open ring: 270° arc starting at 135°, like accessoryCircular.
  const stroke = size * 0.09;
  const r = (size - stroke) / 2;
  const c = size / 2;
  const arc = 2 * Math.PI * r * 0.75;
  const gap = 2 * Math.PI * r * 0.25;

  return (
    <div
      data-slot="gauge"
      role="meter"
      aria-valuenow={value}
      aria-valuemin={min}
      aria-valuemax={max}
      className={cn(
        "relative inline-flex flex-col items-center",
        className
      )}
      style={{ width: size }}
      {...props}
    >
      <svg width={size} height={size} className="rotate-[135deg]">
        <circle
          cx={c}
          cy={c}
          r={r}
          fill="none"
          className="stroke-fill"
          strokeWidth={stroke}
          strokeLinecap="round"
          strokeDasharray={`${arc} ${gap + arc}`}
        />
        <circle
          cx={c}
          cy={c}
          r={r}
          fill="none"
          stroke={tint}
          strokeWidth={stroke}
          strokeLinecap="round"
          strokeDasharray={`${arc * fraction} ${2 * Math.PI * r}`}
          className="transition-[stroke-dasharray] duration-500 ease-[var(--ease-out-expo)]"
        />
      </svg>
      <span
        className="absolute inset-0 flex items-center justify-center font-semibold tabular-nums text-label"
        style={{ fontSize: size * 0.28 }}
      >
        {currentValueLabel ?? Math.round(value)}
      </span>
      {label ? (
        <span className="mt-0.5 text-caption-1 text-secondary-label">
          {label}
        </span>
      ) : null}
    </div>
  );
}

export { Gauge };