Gauge
One value against its scale, with a swinging needle, named zones, lighting steps, a numbered dial or as a row of small gauges. The pointer and the arrow keys read any point of the scale.
One of 6 components in KPI, live and map charts
Variants
Details
What Gauge brings along, read from the registry and the source.
- Category
- KPI, live and map charts
- Area
- Elements
- Variants
- 5
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/gauge.tsx
- Brings along
- utilskiticonchartchart-figure
- npm packages
- None
- Languages
- English and German, 11 texts of its own
- Studio
- All 5 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The GaugeProps interface from components/ui/gauge.tsx, with the comments from the code. Also takes every prop of Omit<ChartA11y, 'label'>, Omit<ChartFigureProps, 'value'>.
labeloptionalstringAccessible name of the chart. Default: the title.
headlineoptionalstringThe big number in the header, formatted. The gauge shows its own value, so most gauges leave it out.
variantoptionalGaugeVariantneedlezonessegmenteddialrow
valueoptionalnumberThe value. row: unused, see items.
minoptionalnumbermaxoptionalnumberformatoptionalIntl.NumberFormatOptionsValue format, e.g. { style: 'percent' } or { style: 'unit', unit: 'kilowatt' }
suffixoptionalstringUnit after the big value, e.g. " kW". Intl knows few units, this works for every one.
unitLabeloptionalstringName of the measure, shown under the value
zonesoptionalGaugeZone[]zones: the named ranges
targetoptionalnumberA target mark on the scale
targetLabeloptionalstringsegmentsoptionalnumbersegmented: number of steps. Default 10.
itemsoptionalGaugeItem[]row: one small gauge per item
Built-in texts
Gauge writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 11 texts
| Key | English | German |
|---|---|---|
| high | English Running high | German Fast ausgelastet |
| span | English {a} to {b} | German {a} bis {b} |
| reading | English Reading | German Ablesen |
| target | English Target | German Ziel |
| of | English {value} of {max} | German {value} von {max} |
| range | English Range | German Bereich |
| zone | English In range {zone} | German Im Bereich {zone} |
| item | English Measure | German Messgröße |
| value | English Value | German Wert |
| scale | English Scale from {min} to {max} | German Skala von {min} bis {max} |
| steps | English {n} of {total} steps | German {n} von {total} Stufen |
Accessibility
Read from the source of the component.
- Gauge handles the keys Escape, Arrow up, Arrow down, Arrow left, Arrow right, Home and End itself.
- Needs no client state. The server writes it in full, so it reads without JavaScript too.
Motion
- With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.
- No animations, it stands still from the start.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 398 lines
'use client';
import { useState } from 'react';
import { cn, round, useTexts } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
import { ChartRoot, ChartSurface, ChartTable, CountUp, arcPath, fill, numberFormatter, polar, tickFormatter, useEntrance, useIntlLocale, useWidth, type ChartA11y } from '@/components/ui/chart';
import { ChartFigure, type ChartFigureProps } from '@/components/ui/chart-figure';
/*
* One value against its scale.
* needle a half circle, the arc fills and a needle swings to the value
* zones the scale in named ranges (good, watch, critical), the needle
* names the range it stands in, with an icon, not color alone
* segmented the arc in equal steps that light up one after another
* dial three quarters of a circle with a numbered scale
* row several small gauges side by side, one value each
*
* The pointer reads the scale: a ghost mark follows it along the arc and
* names the value there. Arrow keys move that reading in steps, Escape
* clears it. The final state is rendered on the server.
*/
export type GaugeVariant = 'needle' | 'zones' | 'segmented' | 'dial' | 'row';
export interface GaugeZone {
/** Upper end of the range, the last one should end at max */
to: number;
label: string;
/** good, watch or critical take the status colors, neutral the grey */
tone: 'good' | 'watch' | 'critical' | 'neutral';
}
export interface GaugeItem {
label: string;
value: number;
min?: number;
max?: number;
/** Note below the value, e.g. "of 16 GB" */
note?: string;
}
export interface GaugeProps extends Omit<ChartA11y, 'label'>, Omit<ChartFigureProps, 'value'> {
/** Accessible name of the chart. Default: the title. */
label?: string;
/** The big number in the header, formatted. The gauge shows its own value, so most gauges leave it out. */
headline?: string;
variant?: GaugeVariant;
/** The value. row: unused, see items. */
value?: number;
min?: number;
max?: number;
/** Value format, e.g. { style: 'percent' } or { style: 'unit', unit: 'kilowatt' } */
format?: Intl.NumberFormatOptions;
/** Unit after the big value, e.g. " kW". Intl knows few units, this works for every one. */
suffix?: string;
/** Name of the measure, shown under the value */
unitLabel?: string;
/** zones: the named ranges */
zones?: GaugeZone[];
/** A target mark on the scale */
target?: number;
targetLabel?: string;
/** segmented: number of steps. Default 10. */
segments?: number;
/** row: one small gauge per item */
items?: GaugeItem[];
className?: string;
}
const TEXTS = {
en: { high: 'Running high', span: '{a} to {b}', reading: 'Reading', target: 'Target', of: '{value} of {max}', range: 'Range', zone: 'In range {zone}', item: 'Measure', value: 'Value', scale: 'Scale from {min} to {max}', steps: '{n} of {total} steps' },
de: { high: 'Fast ausgelastet', span: '{a} bis {b}', reading: 'Ablesen', target: 'Ziel', of: '{value} von {max}', range: 'Bereich', zone: 'Im Bereich {zone}', item: 'Messgröße', value: 'Wert', scale: 'Skala von {min} bis {max}', steps: '{n} von {total} Stufen' },
};
const TONE: Record<GaugeZone['tone'], string> = {
good: 'var(--success)',
watch: 'var(--warning)',
critical: 'var(--destructive)',
neutral: 'var(--muted-foreground)',
};
const TONE_ICON = { good: 'check-circle', watch: 'alert-circle', critical: 'x-circle', neutral: null } as const;
interface ArcGeo {
cx: number;
cy: number;
r: number;
a0: number;
a1: number;
w: number;
h: number;
stroke: number;
}
const arcGeo = (width: number, sweep: 180 | 270, stroke: number, maxR = 150): ArcGeo => {
const r = Math.max(48, Math.min(maxR, (width - stroke - 8) / 2));
if (sweep === 180) {
const h = r + stroke / 2 + 4;
return { cx: round(width / 2), cy: round(r + stroke / 2 + 4), r, a0: -90, a1: 90, w: width, h: round(h + 4), stroke };
}
// 270: the open part at the bottom, the box ends a little below the center
const h = r + stroke / 2 + 4 + r * 0.72;
return { cx: round(width / 2), cy: round(r + stroke / 2 + 4), r, a0: -135, a1: 135, w: width, h: round(h), stroke };
};
/**
* One value against its scale: a needle, named zones, steps, a numbered dial or a row of small gauges.
* <Gauge variant="zones" value={72} zones={[{ to: 60, label: 'Calm', tone: 'good' }, …]} />
*/
export function Gauge(props: GaugeProps) {
const { variant = 'needle', value = 0, min = 0, max = 100, format, suffix = '', unitLabel, zones = [], target, targetLabel, segments = 10, items = [], label, summary, labels, animate = true, title, headline: headValue, delta, meta, source, framed, className } = props;
const t = useTexts(TEXTS, undefined);
const intl = useIntlLocale();
const fmtN = numberFormatter(intl, format);
const fmt = (n: number) => fmtN(n) + suffix;
const [wrapRef, width] = useWidth<HTMLDivElement>(480);
const [rootRef, anim] = useEntrance<HTMLDivElement>(animate);
const [reading, setReading] = useState<number | null>(null);
const name = label ?? title ?? unitLabel ?? '';
const clamp = (v: number, lo = min, hi = max) => Math.max(lo, Math.min(hi, v));
const span = max - min || 1;
/* ---------- row: several small gauges ---------- */
if (variant === 'row') {
const cols = width >= 720 ? Math.min(4, items.length) : width >= 420 ? 2 : 1;
return (
<ChartFigure title={title} value={headValue} delta={delta} meta={meta} source={source} framed={framed} className={className}>
<ChartRoot anim={anim} rootRef={rootRef} className="[--gauge-track:color-mix(in_oklab,var(--foreground)_9%,var(--card))]">
<div ref={wrapRef}>
<ul className="grid gap-px overflow-hidden rounded-lg border border-border bg-border" style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}>
{items.map((it, i) => {
const lo = it.min ?? 0;
const hi = it.max ?? 100;
const share = Math.max(0, Math.min(1, (it.value - lo) / (hi - lo || 1)));
const cell = (width - (cols - 1)) / cols;
const g = arcGeo(Math.min(cell - 32, 200), 180, 10, 80);
const angle = g.a0 + share * (g.a1 - g.a0);
const tone = share >= 0.9 ? 'var(--destructive)' : share >= 0.75 ? 'var(--warning)' : 'var(--primary)';
return (
<li key={it.label} className="group flex flex-col items-center bg-card px-4 pb-4 pt-5">
<div className="relative w-full" style={{ maxWidth: g.w }}>
<svg width="100%" viewBox={`0 0 ${g.w} ${g.h}`} aria-hidden className="block overflow-visible">
<path d={arcPath(g.cx, g.cy, g.r, g.a0, g.a1)} fill="none" stroke="var(--gauge-track)" strokeWidth={g.stroke} strokeLinecap="round" />
<path d={arcPath(g.cx, g.cy, g.r, g.a0, Math.max(g.a0 + 0.5, angle))} pathLength={1} fill="none" stroke={tone} strokeWidth={g.stroke} strokeLinecap="round" className={anim ? 'kitch-draw' : undefined} style={anim ? ({ '--kitch-d': `${150 + i * 120}ms`, '--kitch-t': '1.1s' } as React.CSSProperties) : undefined} />
</svg>
<p className="absolute inset-x-0 bottom-0 text-center text-[22px] font-semibold leading-none tracking-[-0.02em] text-foreground">
<CountUp value={round(share * 100, 0)} format={(n) => `${n}${intl === 'de-DE' ? ' %' : '%'}`} anim={anim} delay={150 + i * 120} />
</p>
</div>
<p className="mt-3 text-center text-[13px] font-medium text-foreground">{it.label}</p>
{it.note && <p className="mt-0.5 text-center text-[12px] tabular-nums text-muted-foreground">{it.note}</p>}
{share >= 0.75 && (
<p className="mt-2 inline-flex items-center gap-1 text-[12px] font-medium text-warning-foreground">
<Icon name="alert-circle" size={13} strokeWidth={2} />
{t.high}
</p>
)}
</li>
);
})}
{/* Empty cells close the last row, so no hairline colour shows through as a grey block */}
{Array.from({ length: (cols - (items.length % cols)) % cols }, (_, k) => (
<li key={`fill${k}`} aria-hidden className="bg-card" />
))}
</ul>
</div>
<ChartTable caption={name} head={[t.item, t.value]} rows={items.map((it) => [it.label, fill(t.of, { value: fmt(it.value), max: fmt(it.max ?? 100) })])} />
</ChartRoot>
</ChartFigure>
);
}
/* ---------- one gauge ---------- */
const sweep = variant === 'dial' ? 270 : 180;
const stroke = variant === 'segmented' ? 18 : variant === 'dial' ? 12 : 16;
const g = arcGeo(Math.min(width, 420), sweep, stroke, variant === 'dial' ? 140 : 160);
const angleOf = (v: number) => g.a0 + ((clamp(v) - min) / span) * (g.a1 - g.a0);
const angle = angleOf(value);
const zone = zones.find((z) => value <= z.to) ?? zones[zones.length - 1];
const zoneStart = (i: number) => (i === 0 ? min : zones[i - 1].to);
const needleAngle = anim === 'wait' ? g.a0 : angle;
const share = (clamp(value) - min) / span;
const lit = Math.round(share * segments);
// Dial ticks: a nice step with 5 to 10 labels
const tickStep = (() => {
const raw = span / 8;
const mag = 10 ** Math.floor(Math.log10(raw));
const n = raw / mag;
return (n <= 1 ? 1 : n <= 2 ? 2 : n <= 2.5 ? 2.5 : n <= 5 ? 5 : 10) * mag;
})();
const ticks: number[] = [];
for (let v = min; v <= max + tickStep / 1000; v += tickStep) ticks.push(round(v, 6));
const readAt = (clientX: number, clientY: number, el: HTMLElement) => {
const svg = el.querySelector('svg');
if (!svg) return;
const b = svg.getBoundingClientRect();
const k = g.w / (b.width || g.w);
const dx = (clientX - b.left) * k - g.cx;
const dy = (clientY - b.top) * k - g.cy;
const dist = Math.hypot(dx, dy);
if (dist < g.r * 0.45 || dist > g.r + stroke * 1.6) return setReading(null);
let deg = (Math.atan2(dy, dx) * 180) / Math.PI + 90;
if (deg > 180) deg -= 360;
if (deg < g.a0 - 8 || deg > g.a1 + 8) return setReading(null);
const v = min + ((Math.max(g.a0, Math.min(g.a1, deg)) - g.a0) / (g.a1 - g.a0)) * span;
setReading(round(v, 6));
};
const step = span / (variant === 'segmented' ? segments : 20);
const onKeyDown = (e: React.KeyboardEvent) => {
const cur = reading ?? value;
let next: number | null = cur;
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') next = clamp(cur + step);
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') next = clamp(cur - step);
else if (e.key === 'Home') next = min;
else if (e.key === 'End') next = max;
else if (e.key === 'Escape') next = null;
else return;
e.preventDefault();
setReading(next === null ? null : round(next, 6));
};
const readZone = reading !== null ? zones.find((z) => reading <= z.to) : undefined;
const live = reading !== null ? `${t.reading} ${fmt(reading)}${readZone ? `, ${readZone.label}` : ''}` : '';
const auto = `${fmt(value)}. ${fill(t.scale, { min: fmt(min), max: fmt(max) })}${zone ? `. ${fill(t.zone, { zone: zone.label })}` : ''}${target !== undefined ? `. ${targetLabel ?? t.target} ${fmt(target)}` : ''}`;
const table = <ChartTable caption={name} head={[t.range, t.value]} rows={[[unitLabel ?? name, fmt(value)], ...(target !== undefined ? [[targetLabel ?? t.target, fmt(target)]] : []), ...zones.map((z, i) => [z.label, fill(t.span, { a: fmt(zoneStart(i)), b: fmt(z.to) })])]} />;
const mainColor = variant === 'zones' && zone ? TONE[zone.tone] : 'var(--primary)';
const readAngle = reading !== null ? angleOf(reading) : null;
const [rx, ry] = readAngle !== null ? polar(g.cx, g.cy, g.r + stroke / 2 + 10, readAngle) : [0, 0];
return (
<ChartFigure title={title} value={headValue} delta={delta} meta={meta} source={source} framed={framed} className={className}>
<ChartRoot anim={anim} rootRef={rootRef} className="[--gauge-track:color-mix(in_oklab,var(--foreground)_9%,var(--card))]">
<div ref={wrapRef} className="flex flex-col items-center">
<ChartSurface
label={name}
summary={summary ?? auto}
labels={labels}
live={live}
table={table}
onKeyDown={onKeyDown}
onBlur={() => setReading(null)}
onPointerMove={(e) => readAt(e.clientX, e.clientY, e.currentTarget)}
onPointerDown={(e) => readAt(e.clientX, e.clientY, e.currentTarget)}
onPointerLeave={(e) => e.pointerType === 'mouse' && setReading(null)}
className="w-full"
style={{ maxWidth: g.w }}
>
<svg width="100%" viewBox={`0 0 ${g.w} ${g.h}`} aria-hidden className="block overflow-visible">
{/* Track */}
{variant !== 'segmented' && <path d={arcPath(g.cx, g.cy, g.r, g.a0, g.a1)} fill="none" stroke="var(--gauge-track)" strokeWidth={stroke} strokeLinecap="round" />}
{/* Zones as quiet bands on the inside */}
{variant === 'zones' &&
zones.map((z, i) => {
const a = angleOf(zoneStart(i));
const b = angleOf(z.to);
const isIn = zone === z;
return (
<path
key={z.label}
d={arcPath(g.cx, g.cy, g.r - stroke / 2 - 9, a + (i ? 0.8 : 0), b - (i < zones.length - 1 ? 0.8 : 0))}
fill="none"
stroke={TONE[z.tone]}
strokeOpacity={isIn ? 1 : 0.45}
strokeWidth={isIn ? 6 : 4}
strokeLinecap="butt"
className={cn('transition-[stroke-opacity,stroke-width] duration-300', anim && 'kitch-fade')}
style={anim ? ({ '--kitch-d': `${200 + i * 120}ms` } as React.CSSProperties) : undefined}
/>
);
})}
{/* Value arc */}
{variant === 'segmented'
? Array.from({ length: segments }, (_, i) => {
const gap = 2.4;
const a = g.a0 + (i * (g.a1 - g.a0)) / segments + gap / 2;
const b = g.a0 + ((i + 1) * (g.a1 - g.a0)) / segments - gap / 2;
const on = i < lit;
return (
<path
key={i}
d={arcPath(g.cx, g.cy, g.r, a, b)}
fill="none"
stroke={on ? 'var(--primary)' : 'var(--gauge-track)'}
strokeWidth={stroke}
className={cn(on && anim && 'kitch-fade')}
style={on && anim ? ({ '--kitch-d': `${200 + i * 90}ms` } as React.CSSProperties) : undefined}
/>
);
})
: value > min && (
<path
d={arcPath(g.cx, g.cy, g.r, g.a0, Math.max(g.a0 + 0.5, angle))}
pathLength={1}
fill="none"
stroke={mainColor}
strokeWidth={stroke}
strokeLinecap="round"
className={anim ? 'kitch-draw' : undefined}
style={anim ? ({ '--kitch-d': '150ms', '--kitch-t': '1.2s' } as React.CSSProperties) : undefined}
/>
)}
{/* Dial scale */}
{variant === 'dial' &&
ticks.map((v, i) => {
const a = angleOf(v);
const [x1, y1] = polar(g.cx, g.cy, g.r - stroke / 2 - 6, a);
const [x2, y2] = polar(g.cx, g.cy, g.r - stroke / 2 - 12, a);
const [tx, ty] = polar(g.cx, g.cy, g.r - stroke / 2 - 26, a);
const minor = polar(g.cx, g.cy, g.r - stroke / 2 - 6, angleOf(v + tickStep / 2));
const minor2 = polar(g.cx, g.cy, g.r - stroke / 2 - 10, angleOf(v + tickStep / 2));
return (
<g key={v}>
<line x1={x1} y1={y1} x2={x2} y2={y2} stroke="var(--kitch-axis)" strokeWidth="1.5" />
{i < ticks.length - 1 && <line x1={minor[0]} y1={minor[1]} x2={minor2[0]} y2={minor2[1]} stroke="var(--kitch-grid)" strokeWidth="1" />}
<text x={tx} y={ty} dy="0.34em" textAnchor="middle" className={cn('text-[11px] tabular-nums', value >= v ? 'fill-foreground' : 'fill-muted-foreground')}>
{tickFormatter(intl, format)(v)}
</text>
</g>
);
})}
{/* Target */}
{target !== undefined &&
(() => {
const a = angleOf(target);
const [x1, y1] = polar(g.cx, g.cy, g.r - stroke / 2 - 3, a);
const [x2, y2] = polar(g.cx, g.cy, g.r + stroke / 2 + 5, a);
return <line x1={x1} y1={y1} x2={x2} y2={y2} stroke="var(--foreground)" strokeWidth="2.5" strokeLinecap="round" className={anim ? 'kitch-fade' : undefined} style={anim ? ({ '--kitch-d': '900ms' } as React.CSSProperties) : undefined} />;
})()}
{/* Needle */}
{(variant === 'needle' || variant === 'zones' || variant === 'dial') && (
<g style={{ transform: `rotate(${round(needleAngle)}deg)`, transformOrigin: `${g.cx}px ${g.cy}px`, transition: anim ? 'transform 1.3s cubic-bezier(.3,1.35,.5,1) .15s' : undefined }} className="motion-reduce:!transition-none">
<path d={`M${g.cx - 3.5} ${g.cy}L${g.cx} ${round(g.cy - g.r + stroke / 2 + 6)}L${g.cx + 3.5} ${g.cy}Z`} fill="var(--foreground)" />
</g>
)}
{(variant === 'needle' || variant === 'zones' || variant === 'dial') && <circle cx={g.cx} cy={g.cy} r={8} fill="var(--foreground)" stroke="var(--kitch-surface)" strokeWidth="3" />}
{/* Reading: a ghost mark that follows the pointer */}
{readAngle !== null && (
<g>
<line x1={polar(g.cx, g.cy, g.r - stroke / 2 - 2, readAngle)[0]} y1={polar(g.cx, g.cy, g.r - stroke / 2 - 2, readAngle)[1]} x2={polar(g.cx, g.cy, g.r + stroke / 2 + 2, readAngle)[0]} y2={polar(g.cx, g.cy, g.r + stroke / 2 + 2, readAngle)[1]} stroke="var(--foreground)" strokeOpacity="0.6" strokeWidth="1.5" />
<text x={rx} y={ry} dy="0.34em" textAnchor={readAngle < -10 ? 'end' : readAngle > 10 ? 'start' : 'middle'} className="text-[12px] font-semibold tabular-nums" fill="var(--foreground)" style={{ paintOrder: 'stroke', stroke: 'var(--kitch-surface)', strokeWidth: 4 }}>
{fmt(reading!)}
</text>
</g>
)}
{/* Scale ends of the half circle */}
{sweep === 180 && (
<g className="text-[11px] tabular-nums" fill="var(--muted-foreground)">
<text x={g.cx - g.r} y={g.cy + 18} textAnchor="middle">
{fmt(min)}
</text>
<text x={g.cx + g.r} y={g.cy + 18} textAnchor="middle">
{fmt(max)}
</text>
</g>
)}
</svg>
</ChartSurface>
{/* The value under the arc */}
<div className={cn('flex flex-col items-center text-center', sweep === 180 ? 'mt-6' : '-mt-10')}>
<p className="text-[34px] font-semibold leading-none tracking-[-0.03em] text-foreground sm:text-[40px]">
<CountUp value={value} format={fmt} anim={anim} duration={1200} delay={150} />
</p>
{unitLabel && <p className="mt-2 text-[13px] text-muted-foreground">{unitLabel}</p>}
{variant === 'segmented' && !unitLabel && <p className="mt-1 text-[12px] tabular-nums text-muted-foreground">{fill(t.steps, { n: String(lit), total: String(segments) })}</p>}
{variant === 'zones' && zone && (
<p className="mt-3 inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-[12px] font-medium text-foreground">
{TONE_ICON[zone.tone] && <Icon name={TONE_ICON[zone.tone]!} size={14} strokeWidth={2} style={{ color: TONE[zone.tone] }} />}
{zone.label}
</p>
)}
{target !== undefined && (
<p className="mt-2 inline-flex items-center gap-1.5 text-[12px] text-muted-foreground">
<span aria-hidden className="h-3 w-[2.5px] rounded-full bg-foreground" />
{targetLabel ?? t.target} <span className="tabular-nums text-foreground">{fmt(target)}</span>
</p>
)}
</div>
{variant === 'zones' && zones.length > 0 && (
<ul className="mt-6 grid w-full max-w-md grid-cols-1 gap-1 sm:grid-cols-3">
{zones.map((z, i) => {
const isIn = zone === z;
return (
<li key={z.label} className={cn('flex items-center gap-2 rounded-md px-2.5 py-2 text-[12px] transition-colors duration-300', isIn ? 'bg-accent text-foreground' : 'text-muted-foreground')}>
<span aria-hidden className="h-2 w-4 shrink-0 rounded-full" style={{ background: TONE[z.tone], opacity: isIn ? 1 : 0.55 }} />
<span className="min-w-0 flex-1 truncate font-medium">{z.label}</span>
<span className="tabular-nums">{fill(t.span, { a: fmt(zoneStart(i)), b: fmt(z.to) })}</span>
</li>
);
})}
</ul>
)}
</div>
</ChartRoot>
</ChartFigure>
);
}Frequent questions
How do I install Gauge?
With aiw add @ai-website/gauge. The command puts components/ui/gauge.tsx into your project, the code is yours from then on. It brings along icon, chart and chart-figure. It needs no further npm packages.
Which variants does Gauge have?
5 variants, Needle, Zones, Steps, Dial and Row of gauges. You pick one with the variant prop, for example variant="zones".
Can I translate the texts of Gauge?
Yes. Gauge carries 11 texts of its own in English and German, such as “Running high”, “Reading” and “Target”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Gauge?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Gauge with aiw_components and install it with aiw_add @ai-website/gauge. Name the variant in your prompt, for example variant="needle", and the assistant writes your content straight into the props.
Can I adjust Gauge without code?
Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching Gauge code to copy.