Skip to main content

Needle Gauge

A rotating needle gauge that displays a single numeric value within a range. Supports alert zones, custom styling, and zone-change callbacks.

Playground

info

onZoneChange callbacks work in your local project but not in this interactive playground due to how the editor re-renders components.

Props

PropTypeDefaultDescription
dataRelayDataPointRequired. The value and timestamp — see data
minnumber0Minimum scale value
maxnumber100Maximum scale value
labelstringLabel text below the value
unitstringUnit suffix (e.g., "°C")
formatValuefunctionCustom value formatter — see formatValue
alertZonesAlertZone[]Color zones on the gauge arc — see Alert Zones
showZoneValuesbooleanfalseShow boundary values on zone edges
stylesobjectCustom fonts and background — see Styles
showLastUpdatedbooleanfalseShow the timestamp from data.timestamp
formatTimestampfunctionCustom timestamp formatter — see formatTimestamp
showLoadingbooleantrueShow skeleton shimmer while data.isLoading is true
onZoneChangefunctionCalled when value enters a new zone — see onZoneChange
onErrorfunctionCalled on invalid data — see onError

data

All components accept a data prop with this shape:

data={{
value: 65, // the current reading
timestamp: Date.now(), // when the reading was taken (ms)
isLoading: false, // optional — shows skeleton shimmer
error: null, // optional — triggers onError
}}
FieldTypeDescription
valueanyThe current reading
timestampnumber | nullWhen the reading was taken (Unix ms)
isLoadingboolean(optional) Shows skeleton shimmer when true
errorError | null(optional) Triggers the onError callback

When using hooks, this maps directly:

const { value, timestamp, isLoading, error } = useRelayLatest({
deviceIdent: "sensor_01",
metric: "temperature",
timeRange: { start: Date.now() - 86400000, end: Date.now() },
});

<NeedleGauge data={{ value, timestamp, isLoading, error }} />

Alert Zones

Color the gauge arc based on value ranges. Each zone needs min, max, color, and an optional label. Zones must not overlap.

Try pasting this into the playground:

alertZones={[
{ min: 0, max: 25, color: "#3b82f6", label: "Cold" },
{ min: 25, max: 50, color: "#22c55e", label: "Normal" },
{ min: 50, max: 75, color: "#f59e0b", label: "Warm" },
{ min: 75, max: 100, color: "#ef4444", label: "Hot" },
]}

The needle color automatically matches the zone it's in. Without alert zones, the gauge renders a single grey arc.


Styles

Customize fonts and background via the styles prop. Every font field accepts fontFamily, fontSize, fontWeight, and color.

styles={{
value: { fontSize: 28, fontWeight: 700, color: "#1e293b" },
label: { fontSize: 14, color: "#64748b" },
unit: { fontSize: 12, color: "#94a3b8" },
minMax: { fontSize: 10, color: "#94a3b8" },
lastUpdated: { fontSize: 10, color: "#94a3b8" },
background: { color: "#f8fafc" },
}}

Try adding this to the playground to see the effect:

<NeedleGauge
data={{ value: 72, timestamp: Date.now() }}
min={0}
max={100}
label="Styled"
unit="°C"
styles={{
value: { fontSize: 32, fontWeight: 800, color: "#7c3aed" },
label: { fontSize: 16, color: "#a78bfa" },
background: { color: "#faf5ff" },
}}
alertZones={[
{ min: 0, max: 50, color: "#a78bfa" },
{ min: 50, max: 100, color: "#7c3aed" },
]}
/>

Style fields reference

FieldApplies toProperties
valueThe large number displayfontFamily, fontSize, fontWeight, color, fontFile
labelThe label below the valuefontFamily, fontSize, fontWeight, color, fontFile
unitThe unit suffixfontFamily, fontSize, fontWeight, color, fontFile
minMaxMin/max labels at arc endsfontFamily, fontSize, fontWeight, color, fontFile
lastUpdatedThe timestamp textfontFamily, fontSize, fontWeight, color, fontFile
backgroundThe component backgroundcolor

Custom Fonts

Use fontFile to load a custom font from a URL. Supports .ttf, .otf, .woff, and .woff2 files. The font is automatically loaded via @font-face — you don't need to add any CSS yourself.

styles={{
value: {
fontFile: "/fonts/CustomFont.woff2",
fontSize: 28,
fontWeight: 700,
},
}}

Additional style options:

FieldTypeDefaultDescription
arcThicknessnumber14Thickness of the gauge arc in pixels
needleThicknessnumber2.5Thickness of the needle in pixels
arcAnglenumber180Sweep angle of the arc in degrees (30–300)
widthnumberFixed width override
heightnumberFixed height override

formatValue

Override how the value number is displayed. Receives the raw number, returns a string.

formatValue={(v) => v.toFixed(1)}
formatValue={(v) => `~${Math.round(v)}`}
formatValue={(v) => `${v}%`}

formatTimestamp

Override how data.timestamp is displayed when showLastUpdated is true.

showLastUpdated={true}
formatTimestamp={(ts) => new Date(ts).toLocaleTimeString()}

onZoneChange

Called whenever the value moves from one alert zone to another.

onZoneChange={(transition) => {
console.log(transition);
// {
// previousZone: { min: 30, max: 70, color: "#22c55e", label: "Normal" },
// currentZone: { min: 70, max: 100, color: "#ef4444", label: "Hot" },
// value: 72
// }
}}
FieldTypeDescription
transition.previousZoneAlertZone | nullThe zone the value was in
transition.currentZoneAlertZone | nullThe zone the value moved to
transition.valuenumberThe current value

onError

Called when the component receives invalid data (NaN, non-finite, non-numeric). The component renders the last valid value and fires this callback.

onError={(error) => {
console.error(error);
// {
// type: "invalid_value",
// message: "NeedleGauge received NaN",
// rawValue: NaN,
// component: "NeedleGauge"
// }
}}
FieldTypeDescription
error.typestring"invalid_value", "invalid_data_point", or "invalid_timestamp"
error.messagestringHuman-readable error description
error.rawValueanyThe invalid value that was passed
error.componentstringThe component name

The component never crashes on bad data — it falls back to the last valid state and notifies you via this callback.


With Hooks

import { useRelayLatest, NeedleGauge } from "@relay-x/ui";

function TemperatureGauge() {
const latest = useRelayLatest({
deviceIdent: "sensor_01",
metric: "temperature",
timeRange: { start: Date.now() - 86400000, end: Date.now() },
});

return (
<NeedleGauge
data={latest}
min={0}
max={100}
label="Temperature"
unit="°C"
showLastUpdated
alertZones={[
{ min: 0, max: 30, color: "#3b82f6", label: "Cold" },
{ min: 30, max: 70, color: "#22c55e", label: "Normal" },
{ min: 70, max: 100, color: "#ef4444", label: "Hot" },
]}
onZoneChange={(t) => console.log(`Zone: ${t.currentZone?.label}`)}
onError={(e) => console.error(e.message)}
/>
);
}