Zum Inhalt springen
UI Kit
Accent

Messanzeige

Ein Wert gegen seine Skala, mit schwingender Nadel, benannten Bereichen, aufleuchtenden Stufen, beziffertem Zifferblatt oder als Reihe kleiner Anzeigen. Zeiger und Pfeiltasten lesen jeden Punkt der Skala ab.

Eine von 6 Components in KPI-, Live- und Karten-Charts

5 Variants

Variants

Nadel

Ein Halbkreis füllt sich bis zum Wert, die Nadel schwingt ein und kommt zur Ruhe, eine Zielmarke sitzt auf der Skala.

Im Studio öffnen

Bereiche

Die Skala in benannten Bereichen. Die Nadel nennt den Bereich, in dem sie steht, mit Symbol und Wort, nie nur mit Farbe.

Im Studio öffnen

Stufen

Der Bogen in gleichen Stufen, die nacheinander aufleuchten, wenn die Anzeige hereinscrollt.

Im Studio öffnen

Zifferblatt

Drei Viertel eines Kreises mit bezifferter Skala, Zwischenstrichen und Nadel, wie ein Instrument.

Im Studio öffnen

Reihe von Anzeigen

Kleine Halbkreise in einem Haarlinienraster, je eine Messgröße. Ab drei Vierteln wechselt der Bogen in den Warnton und ein Hinweis sagt warum.

Im Studio öffnen

Details

Was Messanzeige mitbringt, aus Registry und Quellcode gelesen.

Bereich
Elements
Variants
5
Version
1.0.0 · seit 1.1.0
Datei
components/ui/gauge.tsx
Bringt mit
utilskiticonchartchart-figure
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 11 eigene Texte

Props

Die Schnittstelle GaugeProps aus components/ui/gauge.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von Omit<ChartA11y, 'label'>, Omit<ChartFigureProps, 'value'> an.

  • 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

Eingebaute Texte

Diese Texte schreibt Messanzeige selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 11 Texte anzeigen
highEnglisch Running highDeutsch Fast ausgelastet
spanEnglisch {a} to {b}Deutsch {a} bis {b}
readingEnglisch ReadingDeutsch Ablesen
targetEnglisch TargetDeutsch Ziel
ofEnglisch {value} of {max}Deutsch {value} von {max}
rangeEnglisch RangeDeutsch Bereich
zoneEnglisch In range {zone}Deutsch Im Bereich {zone}
itemEnglisch MeasureDeutsch Messgröße
valueEnglisch ValueDeutsch Wert
scaleEnglisch Scale from {min} to {max}Deutsch Skala von {min} bis {max}
stepsEnglisch {n} of {total} stepsDeutsch {n} von {total} Stufen

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Messanzeige reagiert selbst auf die Tasten Escape, Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts, Pos1 und Ende.
  • Kommt ohne Client-State aus. Der Server schreibt sie vollständig, sie ist auch ohne JavaScript lesbar.

Bewegung

  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 1 Stellen im Code schalten Bewegung dann ab.
  • Ohne Animationen, sie steht sofort ruhig da.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 398 Zeilen
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>
  );
}

Häufige Fragen

Wie installiere ich Messanzeige?

Mit aiw add @ai-website/gauge. Der Befehl legt die Datei components/ui/gauge.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon, chart und chart-figure. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Messanzeige?

5 Varianten, Nadel, Bereiche, Stufen, Zifferblatt und Reihe von Anzeigen. Du wählst sie mit der Prop variant, zum Beispiel variant="zones".

Kann ich die Texte von Messanzeige übersetzen?

Ja. Messanzeige bringt 11 eigene Texte auf Deutsch und Englisch mit, etwa „Fast ausgelastet“, „Ablesen“ und „Ziel“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Messanzeige einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Messanzeige mit aiw_components und installieren sie mit aiw_add @ai-website/gauge. Nenn im Prompt die Variante, etwa variant="needle", dann schreibt der Assistent deine Inhalte direkt in die Props.

Kann ich Messanzeige ohne Code anpassen?

Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden Gauge-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert