A real-time grid-load dial with colored threshold zones; the needle retargets to live values and the active zone follows the reading.
npx shadcn@latest add @paragon/grid-load-gaugeAlso installs: number-ticker
"use client";
import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { NumberTicker } from "@/registry/paragon/ui/number-ticker";
import { cn } from "@/lib/utils";
export interface GridLoadZone {
/** Upper bound of the zone as a fraction of max, 0–1. */
to: number;
color: string;
label: string;
}
const DEFAULT_ZONES: GridLoadZone[] = [
{ to: 0.6, color: "var(--success)", label: "Nominal" },
{ to: 0.85, color: "var(--warning)", label: "Elevated" },
{ to: 1, color: "var(--destructive)", label: "Peak" },
];
export interface GridLoadGaugeProps extends React.ComponentProps<"div"> {
/** Current load. */
value?: number;
max?: number;
/** Threshold zones ascending by `to` (fraction of max). */
zones?: GridLoadZone[];
size?: number;
unit?: string;
/** Renders the final needle position immediately. */
static?: boolean;
}
const START = 135; // degrees, sweep start (bottom-left)
const SWEEP = 270; // total dial arc in degrees
function polar(cx: number, cy: number, r: number, deg: number) {
const rad = ((deg - 90) * Math.PI) / 180;
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
}
function arcPath(cx: number, cy: number, r: number, a0: number, a1: number) {
const p0 = polar(cx, cy, r, a0);
const p1 = polar(cx, cy, r, a1);
const large = a1 - a0 > 180 ? 1 : 0;
return `M ${p0.x} ${p0.y} A ${r} ${r} 0 ${large} 1 ${p1.x} ${p1.y}`;
}
/**
* A real-time grid-load dial. Colored threshold zones tint the track; a needle
* rotates to the current load via a CSS transform transition, so live values
* retarget mid-sweep instead of restarting, and the readout springs on the
* number-ticker. The active zone label and needle color follow the reading. On
* first view the needle sweeps up from zero; reduced motion (or `static`)
* shows the final position.
*/
export function GridLoadGauge({
value = 62,
max = 100,
zones = DEFAULT_ZONES,
size = 220,
unit = "GW",
static: isStatic = false,
className,
...props
}: GridLoadGaugeProps) {
const ref = React.useRef<HTMLDivElement>(null);
const reducedMotion = useReducedMotion();
const inView = useInView(ref, { once: true, margin: "0px 0px -24px 0px" });
const animate = !isStatic && !reducedMotion;
const armed = !animate || inView;
const fraction = Math.min(Math.max(value / max, 0), 1);
const shown = armed ? fraction : 0;
const active =
zones.find((z) => fraction <= z.to) ?? zones[zones.length - 1];
const thickness = 14;
const cx = size / 2;
const cy = size / 2;
const r = size / 2 - thickness / 2 - 2;
const height = size * 0.82;
const needleDeg = START + shown * SWEEP;
let zoneStart = 0;
return (
<div
ref={ref}
data-slot="grid-load-gauge"
className={cn("relative inline-block", className)}
style={{ width: size, height }}
{...props}
>
<svg
role="img"
aria-label={`Grid load ${Math.round(value)} of ${max} ${unit}, ${active.label}`}
width={size}
height={height}
viewBox={`0 0 ${size} ${height}`}
className="block overflow-visible"
>
{/* Track */}
<path
d={arcPath(cx, cy, r, START, START + SWEEP)}
fill="none"
stroke="currentColor"
strokeOpacity={0.08}
strokeWidth={thickness}
strokeLinecap="round"
/>
{/* Threshold zones */}
{zones.map((z, i) => {
const a0 = START + zoneStart * SWEEP;
const a1 = START + Math.min(z.to, 1) * SWEEP;
zoneStart = z.to;
if (a1 <= a0) return null;
return (
<path
key={i}
d={arcPath(cx, cy, r, a0, a1)}
fill="none"
stroke={z.color}
strokeOpacity={0.28}
strokeWidth={thickness}
/>
);
})}
{/* Needle — rotates via transform transition */}
<g
style={{
transform: `rotate(${needleDeg}deg)`,
transformOrigin: `${cx}px ${cy}px`,
transition: animate
? "transform 600ms var(--ease-out)"
: undefined,
}}
>
<line
x1={cx}
y1={cy}
x2={cx}
y2={cy - r + 4}
stroke={active.color}
strokeWidth={2.5}
strokeLinecap="round"
/>
</g>
<circle cx={cx} cy={cy} r={5} fill="var(--card)" stroke={active.color} strokeWidth={2} />
</svg>
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex flex-col items-center">
<div className="flex items-baseline gap-1">
<NumberTicker
value={value}
static={isStatic}
className="text-3xl font-semibold tracking-tight"
/>
<span className="text-sm font-medium text-muted-foreground">
{unit}
</span>
</div>
<span
className="mt-0.5 rounded-full px-2 py-0.5 text-xs font-medium transition-colors duration-300"
style={{ color: active.color, background: `color-mix(in oklch, ${active.color} 12%, transparent)` }}
>
{active.label}
</span>
</div>
</div>
);
}