Dice UI
Components

Gauge

A customizable gauge component that displays values on circular or partial arcs, perfect for dashboards, metrics, and KPIs.

API
import {
  Gauge,
  GaugeIndicator,
  GaugeLabel,
  GaugeRange,
  GaugeTrack,
  GaugeValueText,
} from "@/components/ui/gauge";
 
export function GaugeDemo() {
  return (
    <Gauge value={85} size={180} thickness={12}>
      <GaugeIndicator>
        <GaugeTrack />
        <GaugeRange />
      </GaugeIndicator>
      <GaugeValueText />
      <GaugeLabel className="sr-only">Performance</GaugeLabel>
    </Gauge>
  );
}

Installation

CLI

npx shadcn@latest add @diceui/gauge

Manual

Install the following dependencies:

npm install @radix-ui/react-slot

Copy and paste the following code into your project.

"use client";
 
import { Slot } from "@radix-ui/react-slot";
import * as React from "react";
import { cn } from "@/lib/utils";
 
const GAUGE_NAME = "Gauge";
const INDICATOR_NAME = "GaugeIndicator";
const TRACK_NAME = "GaugeTrack";
const RANGE_NAME = "GaugeRange";
const VALUE_TEXT_NAME = "GaugeValueText";
const LABEL_NAME = "GaugeLabel";
 
const DEFAULT_MAX = 100;
const DEFAULT_START_ANGLE = 0;
const DEFAULT_END_ANGLE = 360;
 
type GaugeState = "indeterminate" | "complete" | "loading";
 
interface DivProps extends React.ComponentProps<"div"> {
  asChild?: boolean;
}
 
interface PathProps extends React.ComponentProps<"path"> {}
 
function getGaugeState(
  value: number | undefined | null,
  maxValue: number,
): GaugeState {
  return value == null
    ? "indeterminate"
    : value === maxValue
      ? "complete"
      : "loading";
}
 
function getIsValidNumber(value: unknown): value is number {
  return typeof value === "number" && Number.isFinite(value);
}
 
function getIsValidMaxNumber(max: unknown): max is number {
  return getIsValidNumber(max) && max > 0;
}
 
function getIsValidValueNumber(
  value: unknown,
  min: number,
  max: number,
): value is number {
  return getIsValidNumber(value) && value <= max && value >= min;
}
 
function getDefaultValueText(value: number, min: number, max: number): string {
  const percentage = max === min ? 100 : ((value - min) / (max - min)) * 100;
  return Math.round(percentage).toString();
}
 
function getInvalidValueError(
  propValue: string,
  componentName: string,
): string {
  return `Invalid prop \`value\` of value \`${propValue}\` supplied to \`${componentName}\`. The \`value\` prop must be a number between \`min\` and \`max\` (inclusive), or \`null\`/\`undefined\` for indeterminate state. The value will be clamped to the valid range.`;
}
 
function getInvalidMaxError(propValue: string, componentName: string): string {
  return `Invalid prop \`max\` of value \`${propValue}\` supplied to \`${componentName}\`. Only numbers greater than 0 are valid. Defaulting to ${DEFAULT_MAX}.`;
}
 
function getNormalizedAngle(angle: number) {
  return ((angle % 360) + 360) % 360;
}
 
function polarToCartesian(
  centerX: number,
  centerY: number,
  radius: number,
  angleInDegrees: number,
) {
  const angleInRadians = ((angleInDegrees - 90) * Math.PI) / 180.0;
  return {
    x: centerX + radius * Math.cos(angleInRadians),
    y: centerY + radius * Math.sin(angleInRadians),
  };
}
 
function describeArc(
  x: number,
  y: number,
  radius: number,
  startAngle: number,
  endAngle: number,
) {
  const angleDiff = endAngle - startAngle;
 
  // For full circles (360 degrees), draw as two semi-circles
  if (Math.abs(angleDiff) >= 360) {
    const mid = polarToCartesian(x, y, radius, startAngle + 180);
    const end = polarToCartesian(x, y, radius, startAngle + 360);
    return [
      "M",
      mid.x,
      mid.y,
      "A",
      radius,
      radius,
      0,
      0,
      1,
      end.x,
      end.y,
      "A",
      radius,
      radius,
      0,
      0,
      1,
      mid.x,
      mid.y,
    ].join(" ");
  }
 
  const start = polarToCartesian(x, y, radius, startAngle);
  const end = polarToCartesian(x, y, radius, endAngle);
  const largeArcFlag = angleDiff <= 180 ? "0" : "1";
 
  return [
    "M",
    start.x,
    start.y,
    "A",
    radius,
    radius,
    0,
    largeArcFlag,
    1,
    end.x,
    end.y,
  ].join(" ");
}
 
interface GaugeContextValue {
  value: number | null;
  valueText: string | undefined;
  max: number;
  min: number;
  state: GaugeState;
  radius: number;
  thickness: number;
  size: number;
  center: number;
  percentage: number | null;
  startAngle: number;
  endAngle: number;
  arcLength: number;
  arcCenterY: number;
  valueTextId?: string;
  labelId?: string;
}
 
const GaugeContext = React.createContext<GaugeContextValue | null>(null);
 
function useGaugeContext(consumerName: string) {
  const context = React.useContext(GaugeContext);
  if (!context) {
    throw new Error(
      `\`${consumerName}\` must be used within \`${GAUGE_NAME}\``,
    );
  }
  return context;
}
 
interface GaugeProps extends DivProps {
  value?: number | null | undefined;
  getValueText?(value: number, min: number, max: number): string;
  min?: number;
  max?: number;
  size?: number;
  thickness?: number;
  startAngle?: number;
  endAngle?: number;
}
 
function Gauge(props: GaugeProps) {
  const {
    value: valueProp = null,
    getValueText = getDefaultValueText,
    min: minProp = 0,
    max: maxProp,
    size = 120,
    thickness = 8,
    startAngle = DEFAULT_START_ANGLE,
    endAngle = DEFAULT_END_ANGLE,
    asChild,
    className,
    ...rootProps
  } = props;
 
  if ((maxProp || maxProp === 0) && !getIsValidMaxNumber(maxProp)) {
    if (process.env.NODE_ENV !== "production") {
      console.error(getInvalidMaxError(`${maxProp}`, GAUGE_NAME));
    }
  }
 
  const rawMax = getIsValidMaxNumber(maxProp) ? maxProp : DEFAULT_MAX;
  const min = getIsValidNumber(minProp) ? minProp : 0;
  const max = rawMax <= min ? min + 1 : rawMax;
 
  if (process.env.NODE_ENV !== "production" && thickness >= size) {
    console.warn(
      `Gauge: thickness (${thickness}) should be less than size (${size}) for proper rendering.`,
    );
  }
 
  if (valueProp !== null && !getIsValidValueNumber(valueProp, min, max)) {
    if (process.env.NODE_ENV !== "production") {
      console.error(getInvalidValueError(`${valueProp}`, GAUGE_NAME));
    }
  }
 
  const value = getIsValidValueNumber(valueProp, min, max)
    ? valueProp
    : getIsValidNumber(valueProp) && valueProp > max
      ? max
      : getIsValidNumber(valueProp) && valueProp < min
        ? min
        : null;
 
  const valueText = getIsValidNumber(value)
    ? getValueText(value, min, max)
    : undefined;
  const state = getGaugeState(value, max);
  const radius = Math.max(0, (size - thickness) / 2);
  const center = size / 2;
 
  const angleDiff = Math.abs(endAngle - startAngle);
  const arcLength = (Math.min(angleDiff, 360) / 360) * (2 * Math.PI * radius);
 
  const percentage = getIsValidNumber(value)
    ? max === min
      ? 1
      : (value - min) / (max - min)
    : null;
 
  // Calculate the visual center Y of the arc for text positioning
  // For full circles, use geometric center. For partial arcs, calculate based on bounding box
  const angleDiffDeg = Math.abs(endAngle - startAngle);
  const isFullCircle = angleDiffDeg >= 360;
 
  let arcCenterY = center;
  if (!isFullCircle) {
    const startRad = (startAngle * Math.PI) / 180;
    const endRad = (endAngle * Math.PI) / 180;
 
    const startY = center - radius * Math.cos(startRad);
    const endY = center - radius * Math.cos(endRad);
 
    let minY = Math.min(startY, endY);
    let maxY = Math.max(startY, endY);
 
    const normStart = getNormalizedAngle(startAngle);
    const normEnd = getNormalizedAngle(endAngle);
 
    const includesTop =
      normStart > normEnd
        ? normStart <= 270 || normEnd >= 270
        : normStart <= 270 && normEnd >= 270;
    const includesBottom =
      normStart > normEnd
        ? normStart <= 90 || normEnd >= 90
        : normStart <= 90 && normEnd >= 90;
 
    if (includesTop) minY = Math.min(minY, center - radius);
    if (includesBottom) maxY = Math.max(maxY, center + radius);
 
    arcCenterY = (minY + maxY) / 2;
  }
 
  const labelId = React.useId();
  const valueTextId = React.useId();
 
  const contextValue = React.useMemo<GaugeContextValue>(
    () => ({
      value,
      valueText,
      max,
      min,
      state,
      radius,
      thickness,
      size,
      center,
      percentage,
      startAngle,
      endAngle,
      arcLength,
      arcCenterY,
      valueTextId,
      labelId,
    }),
    [
      value,
      valueText,
      max,
      min,
      state,
      radius,
      thickness,
      size,
      center,
      percentage,
      startAngle,
      endAngle,
      arcLength,
      arcCenterY,
      valueTextId,
      labelId,
    ],
  );
 
  const RootPrimitive = asChild ? Slot : "div";
 
  return (
    <GaugeContext.Provider value={contextValue}>
      <RootPrimitive
        role="meter"
        aria-describedby={valueText ? valueTextId : undefined}
        aria-labelledby={labelId}
        aria-valuemax={max}
        aria-valuemin={min}
        aria-valuenow={getIsValidNumber(value) ? value : undefined}
        aria-valuetext={valueText}
        data-state={state}
        data-value={value ?? undefined}
        data-max={max}
        data-min={min}
        data-percentage={percentage}
        {...rootProps}
        className={cn(
          "relative inline-flex w-fit flex-col items-center justify-center",
          className,
        )}
      />
    </GaugeContext.Provider>
  );
}
 
function GaugeIndicator(props: React.ComponentProps<"svg">) {
  const { className, ...indicatorProps } = props;
 
  const { size, state, value, max, min, percentage } =
    useGaugeContext(INDICATOR_NAME);
 
  return (
    <svg
      aria-hidden="true"
      focusable="false"
      viewBox={`0 0 ${size} ${size}`}
      data-state={state}
      data-value={value ?? undefined}
      data-max={max}
      data-min={min}
      data-percentage={percentage}
      width={size}
      height={size}
      {...indicatorProps}
      className={cn("transform", className)}
    />
  );
}
 
function GaugeTrack(props: PathProps) {
  const { className, ...trackProps } = props;
 
  const { center, radius, startAngle, endAngle, thickness, state } =
    useGaugeContext(TRACK_NAME);
 
  const pathData = describeArc(center, center, radius, startAngle, endAngle);
 
  return (
    <path
      data-state={state}
      d={pathData}
      fill="none"
      stroke="currentColor"
      strokeWidth={thickness}
      strokeLinecap="round"
      vectorEffect="non-scaling-stroke"
      {...trackProps}
      className={cn("text-muted-foreground/20", className)}
    />
  );
}
 
function GaugeRange(props: PathProps) {
  const { className, ...rangeProps } = props;
 
  const {
    center,
    radius,
    startAngle,
    endAngle,
    value,
    max,
    min,
    state,
    thickness,
    arcLength,
    percentage,
  } = useGaugeContext(RANGE_NAME);
 
  // Always draw the full arc path
  const pathData = describeArc(center, center, radius, startAngle, endAngle);
 
  // Use stroke-dasharray/dashoffset to animate the fill
  const strokeDasharray = arcLength;
  const strokeDashoffset =
    state === "indeterminate"
      ? 0
      : percentage !== null
        ? arcLength - percentage * arcLength
        : arcLength;
 
  return (
    <path
      data-state={state}
      data-value={value ?? undefined}
      data-max={max}
      data-min={min}
      d={pathData}
      fill="none"
      stroke="currentColor"
      strokeWidth={thickness}
      strokeLinecap="round"
      strokeDasharray={strokeDasharray}
      strokeDashoffset={strokeDashoffset}
      vectorEffect="non-scaling-stroke"
      {...rangeProps}
      className={cn(
        "text-primary transition-[stroke-dashoffset] duration-700 ease-out",
        className,
      )}
    />
  );
}
 
function GaugeValueText(props: DivProps) {
  const { asChild, className, children, style, ...valueTextProps } = props;
 
  const { valueTextId, state, arcCenterY, valueText } =
    useGaugeContext(VALUE_TEXT_NAME);
 
  const ValueTextPrimitive = asChild ? Slot : "div";
 
  return (
    <ValueTextPrimitive
      id={valueTextId}
      data-state={state}
      {...valueTextProps}
      style={{
        top: `${arcCenterY}px`,
        ...style,
      }}
      className={cn(
        "absolute right-0 left-0 flex -translate-y-1/2 items-center justify-center font-semibold text-2xl",
        className,
      )}
    >
      {children ?? valueText}
    </ValueTextPrimitive>
  );
}
 
function GaugeLabel(props: DivProps) {
  const { asChild, className, ...labelProps } = props;
 
  const { labelId, state } = useGaugeContext(LABEL_NAME);
 
  const LabelPrimitive = asChild ? Slot : "div";
 
  return (
    <LabelPrimitive
      id={labelId}
      data-state={state}
      {...labelProps}
      className={cn(
        "mt-2 font-medium text-muted-foreground text-sm",
        className,
      )}
    />
  );
}
 
function GaugeCombined(props: GaugeProps) {
  return (
    <Gauge {...props}>
      <GaugeIndicator>
        <GaugeTrack />
        <GaugeRange />
      </GaugeIndicator>
      <GaugeValueText />
    </Gauge>
  );
}
 
export {
  Gauge,
  GaugeIndicator,
  GaugeTrack,
  GaugeRange,
  GaugeValueText,
  GaugeLabel,
  GaugeCombined,
  type GaugeProps,
};

Layout

Import the parts and compose them together.

import {
  Gauge,
  GaugeIndicator,
  GaugeTrack,
  GaugeRange,
  GaugeValueText,
  GaugeLabel,
} from "@/components/ui/gauge";

return (
  <Gauge>
    <GaugeIndicator>
      <GaugeTrack />
      <GaugeRange />
    </GaugeIndicator>
    <GaugeValueText />
    <GaugeLabel>Label</GaugeLabel>
  </Gauge>
)

Or use the Combined component to get all the parts in one.

import { GaugeCombined } from "@/registry/default/ui/gauge";

<GaugeCombined label="Performance" />

Examples

Sizes

Different gauge sizes to fit various UI contexts.

"use client";
 
import { motion, useInView, useMotionValue, useSpring } from "motion/react";
import * as React from "react";
import {
  Gauge,
  GaugeIndicator,
  GaugeLabel,
  GaugeRange,
  GaugeTrack,
  GaugeValueText,
} from "@/components/ui/gauge";
 
const sizes = [
  { size: 100, thickness: 6, label: "Small" },
  { size: 140, thickness: 10, label: "Medium" },
  { size: 180, thickness: 12, label: "Large" },
];
 
export function GaugeSizesDemo() {
  return (
    <div className="flex flex-wrap items-end justify-center gap-8">
      {sizes.map((config, index) => (
        <AnimatedGauge key={config.label} config={config} index={index} />
      ))}
    </div>
  );
}
 
interface AnimatedGaugeProps {
  config: (typeof sizes)[0];
  index: number;
}
 
function AnimatedGauge({ config, index }: AnimatedGaugeProps) {
  const ref = React.useRef(null);
  const isInView = useInView(ref, { once: true, margin: "-100px" });
 
  const motionValue = useMotionValue(0);
  const springValue = useSpring(motionValue, {
    stiffness: 60,
    damping: 15,
    mass: 1,
  });
 
  const [displayValue, setDisplayValue] = React.useState(0);
 
  React.useEffect(() => {
    if (isInView) {
      const delay = index * 150;
      const timer = setTimeout(() => {
        motionValue.set(68);
      }, delay);
 
      return () => clearTimeout(timer);
    }
  }, [isInView, motionValue, index]);
 
  React.useEffect(() => {
    const unsubscribe = springValue.on("change", (latest) => {
      setDisplayValue(Math.round(latest));
    });
 
    return unsubscribe;
  }, [springValue]);
 
  return (
    <motion.div
      ref={ref}
      className="flex flex-col items-center gap-2"
      initial={{ opacity: 0, y: 20 }}
      animate={isInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
      transition={{
        duration: 0.6,
        delay: index * 0.1,
        ease: [0.21, 1.11, 0.81, 0.99],
      }}
    >
      <Gauge
        value={displayValue}
        size={config.size}
        thickness={config.thickness}
        startAngle={-90}
        endAngle={90}
      >
        <GaugeIndicator>
          <GaugeTrack />
          <GaugeRange />
        </GaugeIndicator>
        <GaugeValueText className={config.size < 140 ? "text-xl" : ""} />
        <GaugeLabel className="sr-only">{config.label}</GaugeLabel>
      </Gauge>
      <p className="text-muted-foreground text-sm">{config.label}</p>
    </motion.div>
  );
}

Colors

Different color themes for various use cases like system monitoring, health indicators, and status displays.

"use client";
 
import * as React from "react";
import { cn } from "@/lib/utils";
import {
  Gauge,
  GaugeIndicator,
  GaugeLabel,
  GaugeRange,
  GaugeTrack,
  GaugeValueText,
} from "@/components/ui/gauge";
 
const themes = [
  {
    name: "CPU",
    value: 72,
    trackClass: "text-blue-200 dark:text-blue-900",
    rangeClass: "text-blue-500",
    textClass: "text-blue-700 dark:text-blue-300",
  },
  {
    name: "Memory",
    value: 85,
    trackClass: "text-purple-200 dark:text-purple-900",
    rangeClass: "text-purple-500",
    textClass: "text-purple-700 dark:text-purple-300",
  },
  {
    name: "Disk",
    value: 45,
    trackClass: "text-green-200 dark:text-green-900",
    rangeClass: "text-green-500",
    textClass: "text-green-700 dark:text-green-300",
  },
  {
    name: "Network",
    value: 93,
    trackClass: "text-orange-200 dark:text-orange-900",
    rangeClass: "text-orange-500",
    textClass: "text-orange-700 dark:text-orange-300",
  },
];
 
export function GaugeColorsDemo() {
  const [displayValues, setDisplayValues] = React.useState(themes.map(() => 0));
 
  React.useEffect(() => {
    const interval = setInterval(() => {
      setDisplayValues((prev) =>
        prev.map((val, idx) => {
          const target = themes[idx]?.value ?? 0;
          if (val >= target) return target;
          return val + 1;
        }),
      );
    }, 20);
 
    return () => clearInterval(interval);
  }, []);
 
  return (
    <div className="grid grid-cols-2 gap-6 sm:grid-cols-4">
      {themes.map((theme, index) => (
        <div key={theme.name} className="flex flex-col items-center gap-3">
          <Gauge value={displayValues[index]} size={120} thickness={10}>
            <GaugeIndicator>
              <GaugeTrack className={theme.trackClass} />
              <GaugeRange className={theme.rangeClass} />
            </GaugeIndicator>
            <GaugeValueText className={cn("text-xl", theme.textClass)} />
            <GaugeLabel>{theme.name}</GaugeLabel>
          </Gauge>
        </div>
      ))}
    </div>
  );
}

Variants

Different arc configurations including semi-circle, three-quarter circle, and full circle gauges.

"use client";
 
import * as React from "react";
import {
  Gauge,
  GaugeIndicator,
  GaugeLabel,
  GaugeRange,
  GaugeTrack,
  GaugeValueText,
} from "@/components/ui/gauge";
 
const variants = [
  { startAngle: -90, endAngle: 90, label: "Semi Circle" },
  { startAngle: -135, endAngle: 135, label: "Three Quarter" },
  { startAngle: 0, endAngle: 360, label: "Full Circle" },
];
 
export function GaugeVariantsDemo() {
  const [value, setValue] = React.useState(0);
 
  React.useEffect(() => {
    const interval = setInterval(() => {
      setValue((prev) => {
        if (prev >= 72) {
          clearInterval(interval);
          return 72;
        }
        return prev + 1;
      });
    }, 30);
    return () => clearInterval(interval);
  }, []);
 
  return (
    <div className="flex flex-wrap items-center justify-center gap-12">
      {variants.map((variant) => (
        <div key={variant.label} className="flex flex-col items-center gap-3">
          <Gauge
            value={value}
            size={140}
            thickness={10}
            startAngle={variant.startAngle}
            endAngle={variant.endAngle}
          >
            <GaugeIndicator>
              <GaugeTrack />
              <GaugeRange />
            </GaugeIndicator>
            <GaugeValueText />
            <GaugeLabel>{variant.label}</GaugeLabel>
          </Gauge>
        </div>
      ))}
    </div>
  );
}

Value Text Formatting

By default, the gauge displays the percentage value (0–100) based on value, min, and max. You can customize the format using the getValueText prop:

Show Percentage

<Gauge 
  value={85}
  getValueText={(value, min, max) => {
    const percentage = ((value - min) / (max - min)) * 100;
    return `${Math.round(percentage)}%`;
  }}
>
  {/* ... */}
</Gauge>

Show Fraction

<Gauge 
  value={75}
  max={100}
  getValueText={(value, min, max) => `${value}/${max}`}
>
  {/* ... */}
</Gauge>

Custom Text

<Gauge 
  value={75}
  getValueText={(value) => `${value} points`}
>
  {/* ... */}
</Gauge>

Theming

The gauge component uses CSS currentColor for stroke colors, making it easy to theme using Tailwind's text utilities:

Track Theming

<GaugeTrack className="text-blue-200 dark:text-blue-900" />

Range Theming

<GaugeRange className="text-blue-500" />

Value Text Theming

<GaugeValueText className="text-blue-700 dark:text-blue-300" />

Label Theming

<GaugeLabel className="text-blue-600" />

API Reference

Gauge

The main container component for the gauge.

Prop

Type

Data AttributeValue
[data-state]"indeterminate" | "loading" | "complete"
[data-value]The current gauge value (only present when not indeterminate).
[data-max]The maximum gauge value.
[data-min]The minimum gauge value.
[data-percentage]The normalized gauge value as a decimal between 0 and 1 (only present when not indeterminate).

GaugeIndicator

The SVG container that holds the gauge arc paths.

Prop

Type

Data AttributeValue
[data-state]"indeterminate" | "loading" | "complete"
[data-value]The current gauge value (only present when not indeterminate).
[data-max]The maximum gauge value.
[data-min]The minimum gauge value.
[data-percentage]The normalized gauge value as a decimal between 0 and 1 (only present when not indeterminate).

GaugeTrack

The background arc that represents the full range of possible values.

Prop

Type

Data AttributeValue
[data-state]"indeterminate" | "loading" | "complete"

GaugeRange

The portion of the arc that represents the current gauge value with smooth animations.

Prop

Type

Data AttributeValue
[data-state]"indeterminate" | "loading" | "complete"
[data-value]The current gauge value (only present when not indeterminate).
[data-max]The maximum gauge value.
[data-min]The minimum gauge value.

GaugeValueText

The text element that displays the current gauge value or custom content. Automatically centers within the arc's visual bounds.

Prop

Type

Data AttributeValue
[data-state]"indeterminate" | "loading" | "complete"

GaugeLabel

An optional label element that displays below the gauge.

Prop

Type

Data AttributeValue
[data-state]"indeterminate" | "loading" | "complete"

GaugeCombined

The combined component that includes all the parts.

Prop

Type

Accessibility

Screen Reader Support

  • Uses the meter role for proper screen reader identification
  • Provides aria-valuemin, aria-valuemax, aria-valuenow, and aria-valuetext attributes
  • Supports aria-labelledby when a label prop is provided
  • Supports indeterminate state by omitting aria-valuenow when value is null

Notes

  • The component automatically handles indeterminate states when value is null or undefined
  • Gauge values are automatically clamped to the valid range between min and max
  • Invalid max or value props will log console errors and use fallback values
  • Supports full circles (360°) by automatically splitting into two semi-circles for proper SVG rendering
  • Value text automatically centers within the arc's visual bounds for both full and partial circles
  • The gauge range uses stroke-dashoffset animations for smooth, performant filling effects
  • All stroke colors use currentColor by default, making them responsive to text color changes
  • Default angles are 0° (start) to 360° (end) for a full circle gauge

On this page