Gauge
A customizable gauge component that displays values on circular or partial arcs, perfect for dashboards, metrics, and KPIs.
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/gaugeManual
Install the following dependencies:
npm install @radix-ui/react-slotCopy 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 Attribute | Value |
|---|---|
[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 Attribute | Value |
|---|---|
[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 Attribute | Value |
|---|---|
[data-state] | "indeterminate" | "loading" | "complete" |
GaugeRange
The portion of the arc that represents the current gauge value with smooth animations.
Prop
Type
| Data Attribute | Value |
|---|---|
[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 Attribute | Value |
|---|---|
[data-state] | "indeterminate" | "loading" | "complete" |
GaugeLabel
An optional label element that displays below the gauge.
Prop
Type
| Data Attribute | Value |
|---|---|
[data-state] | "indeterminate" | "loading" | "complete" |
GaugeCombined
The combined component that includes all the parts.
Prop
Type
Accessibility
Screen Reader Support
- Uses the
meterrole for proper screen reader identification - Provides
aria-valuemin,aria-valuemax,aria-valuenow, andaria-valuetextattributes - Supports
aria-labelledbywhen a label prop is provided - Supports indeterminate state by omitting
aria-valuenowwhen value is null
Notes
- The component automatically handles indeterminate states when
valueisnullorundefined - Gauge values are automatically clamped to the valid range between
minandmax - Invalid
maxorvalueprops 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-dashoffsetanimations for smooth, performant filling effects - All stroke colors use
currentColorby default, making them responsive to text color changes - Default angles are 0° (start) to 360° (end) for a full circle gauge