Skip to content
UI Kit
Accent color

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

5 variants

Variants

Needle

A half circle fills to the value while the needle swings in and settles, a target mark sits on the scale.

Open in Studio

Zones

The scale in named ranges. The needle names the range it stands in, with an icon and a word, never color alone.

Open in Studio

Steps

The arc in equal steps that light up one after another as the gauge scrolls in.

Open in Studio

Dial

Three quarters of a circle with a numbered scale, minor ticks and the needle, like an instrument.

Open in Studio

Row of gauges

Small half circles in a hairline grid, one measure each. Above three quarters the arc turns to the warning tone and a note says why.

Open in Studio

Details

What Gauge brings along, read from the registry and the source.

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

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'>.

  • labeloptional
    string

    Accessible name of the chart. Default: the title.

  • headlineoptional
    string

    The big number in the header, formatted. The gauge shows its own value, so most gauges leave it out.

  • variantoptional
    GaugeVariant

    needlezonessegmenteddialrow

  • valueoptional
    number

    The value. row: unused, see items.

  • minoptional
    number
  • maxoptional
    number
  • formatoptional
    Intl.NumberFormatOptions

    Value format, e.g. { style: 'percent' } or { style: 'unit', unit: 'kilowatt' }

  • suffixoptional
    string

    Unit after the big value, e.g. " kW". Intl knows few units, this works for every one.

  • unitLabeloptional
    string

    Name of the measure, shown under the value

  • zonesoptional
    GaugeZone[]

    zones: the named ranges

  • targetoptional
    number

    A target mark on the scale

  • targetLabeloptional
    string
  • segmentsoptional
    number

    segmented: number of steps. Default 10.

  • itemsoptional
    GaugeItem[]

    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
highEnglish Running highGerman Fast ausgelastet
spanEnglish {a} to {b}German {a} bis {b}
readingEnglish ReadingGerman Ablesen
targetEnglish TargetGerman Ziel
ofEnglish {value} of {max}German {value} von {max}
rangeEnglish RangeGerman Bereich
zoneEnglish In range {zone}German Im Bereich {zone}
itemEnglish MeasureGerman Messgröße
valueEnglish ValueGerman Wert
scaleEnglish Scale from {min} to {max}German Skala von {min} bis {max}
stepsEnglish {n} of {total} stepsGerman {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
components/ui/gauge.tsx
'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.

Often searched

Jump to

Copied to the clipboard