# Fear & Greed Gauge

> Crypto Fear & Greed index dial with live data from alternative.me.

A shadcn/ui block from the [@voraui](https://voraui.vercel.app) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/voraui/fear-greed-gauge).

- Page, with a live preview: https://shadcn.exchange/r/voraui/fear-greed-gauge
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/voraui/fear-greed-gauge-light-95d44e0f3e4bfe87.png
- Registry item: https://voraui.vercel.app/r/fear-greed-gauge.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://voraui.vercel.app/r/fear-greed-gauge.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `@number-flow/react`, `framer-motion`
- Registry items: `utils`

## Files

### `registry/voraui/fear-greed-gauge/index.ts`

```ts
export { FearGreedGauge, type FearGreedGaugeProps } from "./fear-greed-gauge";
export { FearGreedGaugeSkeleton } from "./components/skeleton";
export type { FearGreedData } from "./hooks/use-fear-greed";
```

### `registry/voraui/fear-greed-gauge/fear-greed-gauge.tsx`

```tsx
"use client";

import { useEffect, useId, useRef } from "react";
import { animate, useMotionValue } from "framer-motion";
import NumberFlow from "@number-flow/react";
import { cn } from "@/lib/utils";
import { FearGreedGaugeSkeleton } from "./components/skeleton";
import { useFearGreed, type FearGreedData } from "./hooks/use-fear-greed";
import {
  DEFAULT_FEAR_GREED_BANDS,
  GAUGE_CENTER_X,
  GAUGE_CENTER_Y,
  GRADIENT_STOPS,
  WEDGE_GAP,
  WEDGE_HUB_RADIUS,
  WEDGE_INNER_RADIUS,
  WEDGE_OUTER_RADIUS,
  WEDGES_VIEWBOX_HEIGHT,
  angleForValue,
  arcPoint,
  colorForValue,
  describeArc,
  describeWedge,
  equalizedValue,
  findFearGreedBand,
} from "./lib/fear-greed-bands";

export interface FearGreedGaugeProps {
  /** Provide your own data to bypass the bundled alternative.me fetcher. */
  data?: FearGreedData;
  /** "gradient" (default) smooth blend; "minimal" 5 discrete bands; "ticks" 100 tick marks; "wedges" CNN-style zone sectors. */
  variant?: "minimal" | "ticks" | "gradient" | "wedges";
  /** Spring the needle in from neutral on first render instead of snapping. */
  animateOnLoad?: boolean;
  className?: string;
}

const TICKS_MAJOR_VALUES = [0, 25, 50, 75, 100];
const TICKS_FINE_VALUES = Array.from({ length: 100 }, (_, i) => i + 1);

const WEDGE_LABEL_RADIUS = (WEDGE_OUTER_RADIUS + WEDGE_INNER_RADIUS) / 2;
const WEDGE_BAND_SHARE = 100 / DEFAULT_FEAR_GREED_BANDS.length;
// Equalized wedge corners are real band boundaries, so numbers sit at the corners.
const WEDGE_SCALE_NUMBER_VALUES = [0, 25, 45, 55, 75, 100];
// Decorative dots at thirds between the corner numbers (display positions, not values).
const WEDGE_SCALE_DOT_DISPLAY_VALUES = DEFAULT_FEAR_GREED_BANDS.flatMap((_, i) => [
  (i + 1 / 3) * WEDGE_BAND_SHARE,
  (i + 2 / 3) * WEDGE_BAND_SHARE,
]);
const WEDGE_SCALE_DOT_RADIUS = 48;
const WEDGE_SCALE_NUMBER_RADIUS = 56;

function labelAnchor(value: number): "start" | "middle" | "end" {
  if (value < 45) return "start";
  if (value > 55) return "end";
  return "middle";
}

export function FearGreedGauge({
  data,
  variant = "gradient",
  animateOnLoad = true,
  className,
}: FearGreedGaugeProps) {
  const gradientId = useId();
  const fetched = useFearGreed({ enabled: data === undefined });
  const resolved = data ?? fetched.data;
  const loading = data === undefined && fetched.loading;
  const value = resolved?.value ?? null;
  const label = resolved?.label ?? "Unknown";
  const hasError = data === undefined && Boolean(fetched.error);
  // The wedges dial is drawn in equalized space; remap the needle to match.
  const dialValue = value !== null && variant === "wedges" ? equalizedValue(value) : value;
  const needleRotation = dialValue !== null ? 90 - angleForValue(dialValue) : 0;
  const activeBand = variant === "wedges" && value !== null ? findFearGreedBand(value) : null;

  // Drive the SVG rotate() attribute imperatively: framer's transform pipeline
  // drops 3-arg SVG rotate() and would pivot around the wrong origin.
  const needleGroupRef = useRef<SVGGElement | null>(null);
  const needleMV = useMotionValue(needleRotation);
  useEffect(() => {
    return needleMV.on("change", (r) => {
      needleGroupRef.current?.setAttribute("transform", `rotate(${r} ${GAUGE_CENTER_X} ${GAUGE_CENTER_Y})`);
    });
  }, [needleMV]);
  const hasLoadedOnceRef = useRef(false);
  useEffect(() => {
    const justLoaded = value !== null && !hasLoadedOnceRef.current;
    if (value !== null) hasLoadedOnceRef.current = true;

    if (justLoaded && !animateOnLoad) {
      needleMV.set(needleRotation);
      return;
    }
    const controls = animate(needleMV, needleRotation, { type: "spring", stiffness: 100 });
    return controls.stop;
  }, [needleRotation, value, animateOnLoad, needleMV]);

  if (loading) {
    return <FearGreedGaugeSkeleton variant={variant} className={className} />;
  }

  return (
    <div className={cn("relative flex flex-col items-center", className)}>
      <div className="relative w-full max-w-[300px]">
        <svg
          viewBox={variant === "wedges" ? `0 0 260 ${WEDGES_VIEWBOX_HEIGHT}` : "0 0 260 158"}
          className="w-full"
          aria-hidden="true"
        >
          {variant === "minimal" &&
            DEFAULT_FEAR_GREED_BANDS.map((band) => (
              <path
                key={band.key}
                d={describeArc(90, band.min, band.max)}
                fill="none"
                stroke={band.color}
                strokeWidth={12}
                strokeLinecap="round"
              />
            ))}

          {variant === "gradient" && (
            <>
              <defs>
                <linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
                  {GRADIENT_STOPS.map((stop) => (
                    <stop key={stop.value} offset={`${stop.value}%`} stopColor={stop.color} />
                  ))}
                </linearGradient>
              </defs>
              <path
                d={describeArc(90, 0, 100)}
                fill="none"
                stroke={`url(#${gradientId})`}
                strokeWidth={12}
                strokeLinecap="round"
              />
            </>
          )}

          {variant === "ticks" && (
            <>
              {TICKS_FINE_VALUES.map((v) => {
                const inner = arcPoint(96, v);
                const outer = arcPoint(103, v);
                return (
                  <line
                    key={`fine-${v}`}
                    x1={inner.x}
                    y1={inner.y}
                    x2={outer.x}
                    y2={outer.y}
                    strokeWidth={1.2}
                    stroke={colorForValue(v)}
                  />
                );
              })}
              {TICKS_MAJOR_VALUES.map((v) => {
                const inner = arcPoint(92, v);
                const outer = arcPoint(107, v);
                return (
                  <line
                    key={`major-${v}`}
                    x1={inner.x}
                    y1={inner.y}
                    x2={outer.x}
                    y2={outer.y}
                    strokeWidth={2}
                    stroke={colorForValue(v)}
                  />
                );
              })}
              {TICKS_MAJOR_VALUES.map((v) => {
                const p = arcPoint(118, v);
                // The horizontal 0/100 end labels overlap their ticks; push them down a line.
                const isEdge = v === 0 || v === 100;
                return (
                  <text
                    key={`num-${v}`}
                    x={p.x}
                    y={isEdge ? p.y + 14 : p.y}
                    textAnchor={labelAnchor(v)}
                    dominantBaseline="middle"
                    className="fill-muted-foreground text-[9px] font-medium tabular-nums"
                  >
                    {v}
                  </text>
                );
              })}
            </>
          )}

          {variant === "wedges" && (
            <>
              {DEFAULT_FEAR_GREED_BANDS.map((band) => {
                const isActive = activeBand?.key === band.key;
                return (
                  <path
                    key={band.key}
                    d={describeWedge(
                      WEDGE_OUTER_RADIUS,
                      WEDGE_INNER_RADIUS,
                      equalizedValue(band.min) + WEDGE_GAP,
                      equalizedValue(band.max) - WEDGE_GAP,
                    )}
                    fill={isActive ? band.color : undefined}
                    fillOpacity={isActive ? 0.25 : undefined}
                    stroke={isActive ? band.color : undefined}
                    strokeWidth={isActive ? 1.5 : undefined}
                    className={isActive ? undefined : "fill-muted stroke-border"}
                  />
                );
              })}
              {DEFAULT_FEAR_GREED_BANDS.map((band) => {
                const mid = equalizedValue((band.min + band.max) / 2);
                // textPath clips long labels; rotated text with stacked tspans doesn't.
                const rotation = 90 - angleForValue(mid);
                const p = arcPoint(WEDGE_LABEL_RADIUS, mid);
                const words = band.label.toUpperCase().split(" ");
                return (
                  <text
                    key={band.key}
                    textAnchor="middle"
                    dominantBaseline="central"
                    transform={`rotate(${rotation} ${p.x} ${p.y})`}
                    className="fill-foreground text-[7.5px] font-extrabold uppercase tracking-wider"
                  >
                    {words.map((word, i) => (
                      <tspan key={word} x={p.x} y={p.y} dy={`${(i - (words.length - 1) / 2) * 1.3}em`}>
                        {word}
                      </tspan>
                    ))}
                  </text>
                );
              })}
              {WEDGE_SCALE_DOT_DISPLAY_VALUES.map((d) => {
                const p = arcPoint(WEDGE_SCALE_DOT_RADIUS, d);
                return <circle key={`dot-${d}`} cx={p.x} cy={p.y} r={1} className="fill-muted-foreground/50" />;
              })}
              {WEDGE_SCALE_NUMBER_VALUES.map((v) => {
                const isEdge = v === 0 || v === 100;
                const p = arcPoint(WEDGE_SCALE_NUMBER_RADIUS, equalizedValue(v));
                return (
                  <text
                    key={`scale-${v}`}
                    x={p.x}
                    y={isEdge ? p.y - 3 : p.y}
                    textAnchor={labelAnchor(equalizedValue(v))}
                    dominantBaseline="middle"
                    className="fill-muted-foreground text-[8px] font-medium tabular-nums"
                  >
                    {v}
                  </text>
                );
              })}
            </>
          )}

          {value !== null && (
            <g ref={needleGroupRef} transform={`rotate(${needleMV.get()} ${GAUGE_CENTER_X} ${GAUGE_CENTER_Y})`}>
              {variant === "wedges" ? (
                <polygon
                  points={`${GAUGE_CENTER_X - 4},${GAUGE_CENTER_Y - 58} ${GAUGE_CENTER_X},${GAUGE_CENTER_Y - 66} ${GAUGE_CENTER_X + 4},${GAUGE_CENTER_Y - 58} ${GAUGE_CENTER_X + 4},${GAUGE_CENTER_Y + 20} ${GAUGE_CENTER_X - 4},${GAUGE_CENTER_Y + 20}`}
                  fill={activeBand?.color}
                  className={activeBand ? undefined : "fill-foreground"}
                />
              ) : (
                <polygon
                  points={`${GAUGE_CENTER_X - 2.2},${GAUGE_CENTER_Y - 66} ${GAUGE_CENTER_X},${GAUGE_CENTER_Y - 72} ${GAUGE_CENTER_X + 2.2},${GAUGE_CENTER_Y - 66} ${GAUGE_CENTER_X + 2.2},${GAUGE_CENTER_Y + 15} ${GAUGE_CENTER_X - 2.2},${GAUGE_CENTER_Y + 15}`}
                  className="fill-foreground"
                />
              )}
              {variant !== "wedges" && (
                <>
                  <circle cx={GAUGE_CENTER_X} cy={GAUGE_CENTER_Y} r={6} className="fill-foreground" />
                  <circle cx={GAUGE_CENTER_X} cy={GAUGE_CENTER_Y} r={2.5} className="fill-background" />
                </>
              )}
            </g>
          )}

          {variant === "wedges" && (
            <circle
              cx={GAUGE_CENTER_X}
              cy={GAUGE_CENTER_Y}
              r={WEDGE_HUB_RADIUS}
              strokeWidth={1}
              className="fill-background stroke-border"
            />
          )}
        </svg>
        {variant === "wedges" && (
          <div
            className="absolute left-1/2 -translate-x-1/2 -translate-y-1/2 text-center"
            style={{ top: `${(GAUGE_CENTER_Y / WEDGES_VIEWBOX_HEIGHT) * 100}%` }}
          >
            <p className="text-3xl font-bold tabular-nums text-foreground">
              {value !== null ? <NumberFlow value={value} /> : "-"}
              {/* The zone name only exists in the aria-hidden SVG; repeat it for screen readers. */}
              {!hasError && <span className="sr-only"> {label}</span>}
            </p>
          </div>
        )}
      </div>
      {variant === "wedges" ? (
        hasError && (
          <p role="alert" className="text-xs font-medium text-muted-foreground">
            Fear &amp; Greed data is unavailable.
          </p>
        )
      ) : (
        <div className="-mt-4 text-center">
          <p className="text-3xl font-bold tabular-nums text-foreground">
            {value !== null ? <NumberFlow value={value} /> : "-"}
          </p>
          {hasError ? (
            <p role="alert" className="text-xs font-medium text-muted-foreground">
              Fear &amp; Greed data is unavailable.
            </p>
          ) : (
            <p className="text-xs font-medium text-muted-foreground">{label}</p>
          )}
        </div>
      )}
    </div>
  );
}
```

### `registry/voraui/fear-greed-gauge/components/skeleton.tsx`

```tsx
"use client";

import { cn } from "@/lib/utils";
import {
  DEFAULT_FEAR_GREED_BANDS,
  GAUGE_CENTER_X,
  GAUGE_CENTER_Y,
  WEDGE_GAP,
  WEDGE_HUB_RADIUS,
  WEDGE_INNER_RADIUS,
  WEDGE_OUTER_RADIUS,
  WEDGES_VIEWBOX_HEIGHT,
  describeArc,
  describeWedge,
} from "../lib/fear-greed-bands";

export interface FearGreedGaugeSkeletonProps {
  /** Matches FearGreedGaugeProps["variant"] so the ghost lines up with the real dial. */
  variant?: "minimal" | "ticks" | "gradient" | "wedges";
  className?: string;
}

const WEDGE_COUNT = DEFAULT_FEAR_GREED_BANDS.length;

export function FearGreedGaugeSkeleton({ variant = "gradient", className }: FearGreedGaugeSkeletonProps) {
  const isWedges = variant === "wedges";

  return (
    <div
      role="status"
      className={cn("voraui-fear-greed-gauge-skeleton-shimmer relative flex flex-col items-center overflow-hidden", className)}
    >
      <style href="voraui-fear-greed-gauge-skeleton" precedence="low">{`
        @keyframes voraui-fear-greed-gauge-skeleton-shimmer {
          from { transform: translateX(-100%); }
          to { transform: translateX(100%); }
        }
        .voraui-fear-greed-gauge-skeleton-shimmer::after {
          content: "";
          position: absolute;
          inset: 0;
          background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.25), transparent);
          animation: voraui-fear-greed-gauge-skeleton-shimmer 1.8s ease-in-out infinite;
        }
        @media (prefers-reduced-motion: reduce) {
          .voraui-fear-greed-gauge-skeleton-shimmer::after {
            animation: none;
          }
        }
      `}</style>
      <div className="relative w-full max-w-[300px]">
        <svg
          viewBox={isWedges ? `0 0 260 ${WEDGES_VIEWBOX_HEIGHT}` : "0 0 260 158"}
          className="w-full"
          aria-hidden="true"
        >
          {isWedges ? (
            Array.from({ length: WEDGE_COUNT }, (_, i) => {
              const share = 100 / WEDGE_COUNT;
              const from = i * share + WEDGE_GAP;
              const to = (i + 1) * share - WEDGE_GAP;
              return (
                <path
                  key={i}
                  d={describeWedge(WEDGE_OUTER_RADIUS, WEDGE_INNER_RADIUS, from, to)}
                  className="fill-muted"
                />
              );
            })
          ) : (
            <path
              d={describeArc(90, 0, 100)}
              fill="none"
              stroke="currentColor"
              strokeWidth={12}
              strokeLinecap="round"
              className="text-muted"
            />
          )}
          <polygon
            points={`${GAUGE_CENTER_X - 2.2},${GAUGE_CENTER_Y - 66} ${GAUGE_CENTER_X},${GAUGE_CENTER_Y - 72} ${GAUGE_CENTER_X + 2.2},${GAUGE_CENTER_Y - 66} ${GAUGE_CENTER_X + 2.2},${GAUGE_CENTER_Y + 15} ${GAUGE_CENTER_X - 2.2},${GAUGE_CENTER_Y + 15}`}
            className="fill-muted-foreground/40"
          />
          {isWedges && (
            <circle
              cx={GAUGE_CENTER_X}
              cy={GAUGE_CENTER_Y}
              r={WEDGE_HUB_RADIUS}
              strokeWidth={1}
              className="fill-background stroke-border"
            />
          )}
        </svg>
        {isWedges && (
          <div
            className="absolute left-1/2 -translate-x-1/2 -translate-y-1/2"
            style={{ top: `${(GAUGE_CENTER_Y / WEDGES_VIEWBOX_HEIGHT) * 100}%` }}
          >
            <div className="mx-auto h-9 w-14 rounded-md bg-muted" />
          </div>
        )}
      </div>
      {!isWedges && (
        <div className="-mt-4 flex flex-col items-center">
          <div className="h-9 w-16 rounded-md bg-muted" />
          <div className="h-4 w-20 rounded-full bg-muted" />
        </div>
      )}
      <span className="sr-only">Loading Fear &amp; Greed Index</span>
    </div>
  );
}
```

### `registry/voraui/fear-greed-gauge/hooks/use-fear-greed.ts`

```ts
"use client";

import { useEffect, useState } from "react";

export interface FearGreedData {
  /** 0-100 index value, or null when the upstream has no data. */
  value: number | null;
  /** Upstream classification, e.g. "Fear", "Neutral", "Greed". */
  label: string;
  /** Upstream unix-seconds timestamp string, if provided. */
  updatedAt: string | null;
}

const FEAR_GREED_URL = "https://api.alternative.me/fng/?limit=1";

export function parseFngResponse(raw: unknown): FearGreedData {
  const rows = (raw as { data?: Array<Record<string, unknown>> } | null | undefined)?.data ?? [];
  const row = rows[0];
  if (!row) return { value: null, label: "Unknown", updatedAt: null };
  const parsed = Number(row.value);
  return {
    value: Number.isFinite(parsed) ? parsed : null,
    label: typeof row.value_classification === "string" ? row.value_classification : "Neutral",
    updatedAt: typeof row.timestamp === "string" ? row.timestamp : null,
  };
}

export function useFearGreed(
  options: { enabled?: boolean; refreshInterval?: number } = {},
) {
  const { enabled = true, refreshInterval = 600_000 } = options;
  const [data, setData] = useState<FearGreedData | null>(null);
  const [loading, setLoading] = useState(enabled);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    if (!enabled) return;
    let cancelled = false;

    const load = async () => {
      try {
        const res = await fetch(FEAR_GREED_URL);
        if (!res.ok) throw new Error(`Fear & Greed request failed: ${res.status}`);
        const raw = await res.json();
        if (cancelled) return;
        setData(parseFngResponse(raw));
        setError(null);
        setLoading(false);
      } catch (err) {
        if (cancelled) return;
        setError(err instanceof Error ? err.message : "Fear & Greed request failed");
        setLoading(false);
      }
    };

    load();
    const timer = setInterval(load, refreshInterval);
    return () => {
      cancelled = true;
      clearInterval(timer);
    };
  }, [enabled, refreshInterval]);

  return { data, loading, error };
}
```

### `registry/voraui/fear-greed-gauge/lib/fear-greed-bands.ts`

```ts
export interface FearGreedBand {
  key: string;
  label: string;
  min: number;
  max: number;
  color: string;
}

/** Boundaries mirror alternative.me's value_classification thresholds. */
export const DEFAULT_FEAR_GREED_BANDS: FearGreedBand[] = [
  { key: "extreme-fear", label: "Extreme Fear", min: 0, max: 24, color: "#c0392b" },
  { key: "fear", label: "Fear", min: 25, max: 44, color: "#e0672b" },
  { key: "neutral", label: "Neutral", min: 45, max: 55, color: "#f0c929" },
  { key: "greed", label: "Greed", min: 56, max: 75, color: "#4caf50" },
  { key: "extreme-greed", label: "Extreme Greed", min: 76, max: 100, color: "#2e7d32" },
];

export const GAUGE_CENTER_X = 130;
export const GAUGE_CENTER_Y = 130;

/** Angle in degrees for a 0-100 value along the top semicircle (180 at 0, 0 at 100). */
export function angleForValue(value: number): number {
  const clamped = Math.min(Math.max(value, 0), 100);
  return 180 - (clamped / 100) * 180;
}

/** Point at radius for a 0-100 value; rounded to 4 decimals to avoid hydration mismatches. */
export function arcPoint(radius: number, value: number): { x: number; y: number } {
  const rad = (angleForValue(value) * Math.PI) / 180;
  return {
    x: Math.round((GAUGE_CENTER_X + radius * Math.cos(rad)) * 10000) / 10000,
    y: Math.round((GAUGE_CENTER_Y - radius * Math.sin(rad)) * 10000) / 10000,
  };
}

/** SVG arc path between two values along the top semicircle. */
export function describeArc(radius: number, fromValue: number, toValue: number): string {
  const start = arcPoint(radius, fromValue);
  const end = arcPoint(radius, toValue);
  return `M ${start.x} ${start.y} A ${radius} ${radius} 0 0 1 ${end.x} ${end.y}`;
}

/** SVG path for an annular wedge between two radii and two values. */
export function describeWedge(
  outerRadius: number,
  innerRadius: number,
  fromValue: number,
  toValue: number,
): string {
  const outerStart = arcPoint(outerRadius, fromValue);
  const outerEnd = arcPoint(outerRadius, toValue);
  const innerEnd = arcPoint(innerRadius, toValue);
  const innerStart = arcPoint(innerRadius, fromValue);
  return (
    `M ${outerStart.x} ${outerStart.y} A ${outerRadius} ${outerRadius} 0 0 1 ${outerEnd.x} ${outerEnd.y} ` +
    `L ${innerEnd.x} ${innerEnd.y} A ${innerRadius} ${innerRadius} 0 0 0 ${innerStart.x} ${innerStart.y} Z`
  );
}

/** Band for a 0-100 value; out-of-range clamps to the nearest edge band. */
export function findFearGreedBand(
  value: number,
  bands: FearGreedBand[] = DEFAULT_FEAR_GREED_BANDS,
): FearGreedBand {
  const clamped = Math.min(Math.max(value, 0), 100);
  return bands.find((band) => clamped >= band.min && clamped <= band.max) ?? bands[bands.length - 1];
}

/** Remap a 0-100 value onto a CNN-style dial where every band gets an equal
 *  angular share; the needle, dots, and scale numbers all use this mapping. */
export function equalizedValue(
  value: number,
  bands: FearGreedBand[] = DEFAULT_FEAR_GREED_BANDS,
): number {
  const clamped = Math.min(Math.max(value, 0), 100);
  const band = findFearGreedBand(clamped, bands);
  const index = bands.indexOf(band);
  const share = 100 / bands.length;
  return index * share + ((clamped - band.min) / (band.max - band.min)) * share;
}

export interface GradientStop {
  value: number;
  color: string;
}

/** Evenly-spaced band colors used to interpolate the per-tick gradient. */
export const GRADIENT_STOPS: GradientStop[] = [
  { value: 0, color: "#c0392b" },
  { value: 25, color: "#e0672b" },
  { value: 50, color: "#f0c929" },
  { value: 75, color: "#4caf50" },
  { value: 100, color: "#2e7d32" },
];

function hexToRgb(hex: string): [number, number, number] {
  const clean = hex.replace("#", "");
  const num = parseInt(clean, 16);
  return [(num >> 16) & 255, (num >> 8) & 255, num & 255];
}

function rgbToHex(r: number, g: number, b: number): string {
  return `#${[r, g, b].map((c) => Math.round(c).toString(16).padStart(2, "0")).join("")}`;
}

/** Interpolated hex color for a 0-100 value between GRADIENT_STOPS. */
export function colorForValue(value: number, stops: GradientStop[] = GRADIENT_STOPS): string {
  const clamped = Math.min(Math.max(value, 0), 100);
  for (let i = 0; i < stops.length - 1; i++) {
    const from = stops[i];
    const to = stops[i + 1];
    if (clamped >= from.value && clamped <= to.value) {
      const t = (clamped - from.value) / (to.value - from.value);
      const [r1, g1, b1] = hexToRgb(from.color);
      const [r2, g2, b2] = hexToRgb(to.color);
      return rgbToHex(r1 + (r2 - r1) * t, g1 + (g2 - g1) * t, b1 + (b2 - b1) * t);
    }
  }
  return stops[stops.length - 1].color;
}

// Wedge geometry, shared with the skeleton so the ghost dial lines up.
export const WEDGE_OUTER_RADIUS = 104;
export const WEDGE_INNER_RADIUS = 64;
export const WEDGE_GAP = 1.2;
export const WEDGE_HUB_RADIUS = 40;
// Fits the hub circle plus margin; also sizes the centered value overlay.
export const WEDGES_VIEWBOX_HEIGHT = 180;
```
