A collection of pre-built, themeable React widgets for displaying Anedya IoT data in dashboards and front-end applications.
Designed for developers using the Anedya Frontend SDK, the library provides drop-in UI components that fetch and display device data with minimal setup. Each widget is fully customizable through themes, Tailwind classes, and component props, making it easy to match your application's design while avoiding repetitive UI implementation.
Widgets in this SDK don't create their own Anedya client or node — they only render data you fetch through the Anedya Frontend SDK, which is a separate package you'll need alongside this one.
1. Install both packages:
npm install @anedyasystems/anedya-frontend-sdk @anedyasystems/anedya-widgets-react2. Import the stylesheet once, anywhere in your app's entry point:
import "anedya-widgets-react/styles.css";3. Create a client and node using the Frontend SDK, then pass the node into any widget:
import { Anedya } from "@anedyasystems/anedya-frontend-sdk";
import { AnedyaCard } from "@anedyasystems/anedya-widgets-react";
import "anedya-widgets-react/styles.css";
const anedya = new Anedya();
const config = anedya.newConfig(tokenId, token);
const client = anedya.newClient(config);
const node = anedya.newNode(client, nodeId);
<AnedyaCard node={node} variable="humidity" />;
tokenId,token, andnodeIdcome from your Anedya account/device setup — see the Frontend SDK docs for details on obtaining these and on othernode/clientmethods (fetching historical data, live streaming, key-value store, etc.) beyond what these widgets use directly.
A single-value display widget — shows the latest reading, a title, and a last-updated label.
<AnedyaCard
node={node}
variable="humidity"
title="Humidity"
unit="%"
decimalPlaces={1}
/>| Prop | Type | Description |
|---|---|---|
node |
any |
anedya.newNode(client, nodeId) — omit only if using value for a fully controlled widget (see below) |
variable |
string |
Variable name to display — required whenever node is provided |
Like AnedyaGauge, AnedyaCard can also render without a live node fetch — pass value directly for a fully controlled card:
<AnedyaCard value={72} unit="%" title="Manual reading" />If both a node/variable fetch and a value prop are present, the fetched value takes over once it arrives — value acts as the initial/fallback reading until then.
onDataChange reflects live fetched data only — it does not fire based on the manual value prop.
AnedyaCard is entirely class-based — every visual choice is a CSS class string, not a style property. This is deliberate: card widgets are commonly styled with Tailwind, and classes compose and override far more predictably than inline styles do once you're combining a theme, a per-instance override, and a conditional rule all on the same element.
Every part of the card is a named slot:
type CardSlot = "container" | "title" | "value" | "unit" | "label";These look similar but do genuinely different things:
| Shape | Targets | Purpose | |
|---|---|---|---|
styles |
object, keyed by slot | any inner slot (title, value, unit, label) and container |
reach inside the widget |
className |
plain string | only the outermost container element | the ordinary React convention every component supports |
<AnedyaCard
className="shadow-lg" // outer box only
styles={{
value: "text-red-500 text-5xl", // the "value" slot specifically
title: "uppercase tracking-wider", // the "title" slot specifically
}}
/>Both can be used together freely — className is merged (via twMerge) with whatever styles.container already resolved to; it doesn't replace it.
Note:
stylesclasses are consumer-authored strings forwarded straight intoclassNameat render — the widget doesn't generate any CSS for them at build time. Any class you pass here must already exist in your own app's compiled CSS (e.g. your own Tailwind build, or a plain hand-written class in a stylesheet you import).
theme accepts a built-in preset name, or a full custom WidgetTheme<CardSlot> object — just a plain object shaped like styles, reusable across every widget instance that uses it:
const emeraldTheme = {
styles: {
container: "bg-emerald-50 border-emerald-200",
title: "text-emerald-700",
value: "text-emerald-900",
label: "text-emerald-500",
},
};
<AnedyaCard theme={emeraldTheme} />
// or
<AnedyaCard theme="dark" /> // built-in preset, or "light" (default)You can freely use Tailwind's responsive prefixes inside styles:
<AnedyaCard
styles={{
value: "text-2xl md:text-4xl xl:text-6xl",
}}
/>These breakpoints come entirely from your application's Tailwind configuration and respond to the browser viewport. They are independent of the widget's built-in container-query sizing (see Responsive sizing below).
If you supply your own text-* classes (responsive or otherwise), they replace the widget's default responsive typography for that slot.
onDataChange is called whenever the latest fetched data changes.
It receives the raw fetched data:
{
value: number;
timestamp: number;
}(or null while no data is available).
Instead of returning CSS classes, it returns a partial set of widget props. Those returned props temporarily override the widget's original props until the callback runs again.
<AnedyaCard
onDataChange={(data) => {
if (!data) return;
if (data.value > 80) {
return {
title: "High Humidity",
theme: "dark",
styles: {
value: "text-red-500",
},
};
}
}}
/>Returning undefined (or nothing) clears any previous overrides and restores the widget's original props.
onDataChange also receives a second argument describing which state just occurred — see Error & empty states below for the full meta shape and examples reacting to errors/empty data.
Rendering happens in layers.
- The props you pass to
<AnedyaCard /> - Any temporary overrides returned from
onDataChange - Theme resolution (
"light","dark", or a customWidgetTheme) - Per-slot class resolution
For each slot (container, title, value, unit, label), classes are merged in this order:
CARD_DEFAULT_CLASSES- Active theme classes
styles
Later layers win whenever two Tailwind utilities conflict. Conflicts are resolved with twMerge.
className is separate from this chain — it is merged onto the outer container after the container slot has been resolved.
Rule of thumb: defaults → theme →
styles→onDataChange. User overrides always win.
A radial arc gauge — shows a single value as a filled arc with an optional needle, min/max tick marks, and a last-updated label.
<AnedyaGauge
node={node}
variable="temperature"
title="Temperature"
unit="°C"
min={0}
max={100}
/>Default behaviour: simply pass
nodeandvariable— the gauge renders with the light theme, title"Latest Value", no tick marks, and the last‑updated label showing the current time.
| Prop | Type | Description |
|---|---|---|
node |
any |
anedya.newNode(client, nodeId) — omit only if using value for a fully controlled widget (see below) |
variable |
string |
Variable name to display — required whenever node is provided |
Unlike AnedyaCard, AnedyaGauge can also render without a live node fetch — pass value directly for a fully controlled gauge (e.g. driven by your own state, a slider, or a value computed elsewhere in your app):
<AnedyaGauge value={72} min={0} max={100} unit="%" />If both a node/variable fetch and a value prop are present, the fetched value takes over once it arrives — value acts as the initial/fallback reading until then.
min and max (default 0 / 100) define the gauge's range regardless of which mode you're in. The displayed value is always clamped into [min, max] before it's drawn or formatted.
Like AnedyaCard, every part of the gauge is a named slot, and the same styles (per-slot) / className (outer container only) system applies:
type GaugeSlot =
| "container" | "title" | "unit" | "track" | "bar"
| "needle" | "needleCap" | "value" | "label"
| "tick" | "tickLabel" | "tooltip" | "error" | "empty";<AnedyaGauge
className="shadow-lg" // outer box only
styles={{
value: "text-red-500 text-4xl",
bar: "text-emerald-500", // arc fill reads this via currentColor
needle: "text-slate-800",
}}
/>Note:
track,bar,needle, andneedleCapare SVG shapes, not text — their "color" comes fromcurrentColor, which is why these slots are styled with Tailwind text-color utilities (text-emerald-500) rather thanfill-*/stroke-*classes.
theme works exactly as it does for AnedyaCard — a preset name, or a custom WidgetTheme<GaugeSlot> object:
const emeraldGaugeTheme = {
styles: {
bar: "text-emerald-500",
track: "text-emerald-100",
value: "text-emerald-900",
needle: "text-emerald-700",
needleCap: "text-emerald-700",
tooltip: "bg-emerald-200 text-emerald-700",
},
};
<AnedyaGauge theme={emeraldGaugeTheme} />
// or
<AnedyaGauge theme="dark" /> // built-in preset, or "light" (default)arc controls the shape of the gauge itself:
<AnedyaGauge
arc={{
startAngle: -120, // degrees, 0 = 12 o'clock, clockwise-positive
endAngle: 120,
radius: 90, // px; auto-fit to the container if omitted
thickness: 14, // px; defaults to ~18% of radius if omitted
cornerRadius: 6, // rounds the ends of the arc/bar
}}
/>| Prop | Type | Default | Description |
|---|---|---|---|
startAngle |
number |
-90 |
Degrees, 0 = 12 o'clock, clockwise-positive |
endAngle |
number |
90 |
Degrees, same convention as startAngle |
radius |
number |
auto-fit to container | Capped to whatever actually fits the current width/height/tick space |
thickness |
number |
~18% of resolved radius |
Arc/bar stroke width in px |
cornerRadius |
number |
0 |
Rounds the ends of both the track and the value bar |
A default startAngle/endAngle of -90/90 draws the classic bottom half-circle speedometer; narrowing the range (e.g. -120/120) draws a fuller arc.
radius is a maximum — the gauge always shrinks it further if needed so the arc (plus tick marks, if shown) fits inside the widget's actual rendered box.
track is the background arc the value bar sits on top of:
<AnedyaGauge track={{ show: true, color: "#e2e8f0" }} />| Prop | Type | Default | Description |
|---|---|---|---|
show |
boolean |
true |
Whether the background track arc is drawn |
color |
string |
theme's track slot |
Overrides the track color directly (any CSS color) |
color sets the fill of the value arc — a single color, or 2+ colors for a gradient:
<AnedyaGauge color="#f97316" />
<AnedyaGauge color={["#22c55e", "#eab308", "#ef4444"]} />
// renders a left-to-right gradient across the filled portion of the arcIf omitted, the bar falls back to the theme's bar slot color via currentColor.
<AnedyaGauge
needle={{
show: true,
type: "drop", // "line" | "rounded" | "drop" | "triangle"
length: "medium", // "short" | "medium" | "full" | number (px)
width: 6,
color: "#334155", // shorthand: sets both needleColor and capColor
capRadius: 8,
animation: true,
}}
/>| Prop | Type | Default | Description |
|---|---|---|---|
show |
boolean |
true |
Whether the needle is drawn at all |
type |
"line" | "rounded" | "drop" | "triangle" |
"rounded" |
Needle shape |
length |
"short" | "medium" | "full" | number |
"medium" |
Preset (relative to the resolved radius) or an explicit px value |
width |
number |
4 |
Needle thickness in px |
color |
string |
theme |
Shorthand — sets both needleColor and capColor at once |
needleColor |
string |
color or theme |
Color of the needle shape specifically, overrides color for the shape only |
capColor |
string |
color or theme |
Color of the center cap circle specifically |
capRadius |
number |
6 |
Radius of the center cap circle in px |
animation |
boolean |
true |
Whether the needle rotates smoothly to its new position |
needle.animation and the top-level animation.show (below) are independent switches — both must be true for the needle to animate. This lets you, for example, animate the bar fill while snapping the needle instantly, or vice versa.
Setting needle={{ show: false }} turns the gauge into a donut-style indicator — the value/unit/label block is centered directly over the arc instead of rendered below it:
<AnedyaGauge needle={{ show: false }} arc={{ startAngle: -180, endAngle: 180 }} />tick draws radial marks (and optional labels) around the arc between min and max. Off by default:
<AnedyaGauge
tick={{
show: true,
count: 10, // number of INTERVALS, so 10 -> 11 marks (0,10,...,100)
size: 6,
radiusOffset: 4, // gap between the arc's outer edge and the tick line
labelGap: 4, // gap between the tick line and its label
labelSize: 10, // px — always wins over any styles.tickLabel font-size class
color: "#cbd5e1",
labelColor: "#94a3b8",
labelFormat: (v) => `${v}°`,
}}
/>| Prop | Type | Default | Description |
|---|---|---|---|
show |
boolean |
false |
Whether tick marks are drawn |
count |
number |
10 |
Number of intervals between min and max — count: 10 draws 11 marks |
size |
number |
6 |
Tick line length in px |
color |
string |
theme |
Tick line color; falls back to the theme's tick slot via currentColor |
radiusOffset |
number |
4 |
Gap in px between the arc's outer edge and the start of the tick line |
labelGap |
number |
4 |
Gap in px between the end of the tick line and its label |
labelSize |
number |
10 |
Tick label font size in px — set as an inline style, so it always wins over styles.tickLabel |
labelColor |
string |
theme |
Tick label color; falls back to the theme's tickLabel slot |
labelFormat |
(value: number) => string |
— | Custom formatter for tick numbers. If omitted, reuses the widget's format preset if one is set, otherwise a plain rounded number |
Enabling ticks reserves extra space inside the widget for the line + gaps + label, shrinking the arc's radius slightly so the ring is never clipped — you don't need to size the container any differently to accommodate it.
Because
labelSizeis applied as an inline style, astyles.tickLabelclass controlling font size will have no effect — use thelabelSizeprop for sizing, andstyles.tickLabel/tickLabeltheme classes for anything else (weight, tracking, etc.).
tooltip shows a hover tooltip over the arc (both the track and the filled bar respond) — enabled by default. The built-in tooltip text is "variable: value unit", using the formatted value (displayValue — after format/formatValue/decimalPlaces is applied), not the raw number:
<GaugeWidget node={node} variable="temperature" unit="°C" decimalPlaces={1} />
// hovering the arc shows: "temperature: 42.5 °C"| Prop | Type | Default | Description |
|---|---|---|---|
show |
boolean |
true |
Whether hovering the arc shows a tooltip |
content |
(data: { variable: string; value: number; displayValue: string; unit?: string }) => ReactNode |
— | Full custom tooltip content. Receives both the raw clamped value and the already-formatted displayValue/unit, alongside the widget's variable name |
Disable it with tooltip={{ show: false }}, or fully replace the content with your own JSX — content gets the raw value in case you want to do your own formatting/thresholds rather than reuse displayValue:
<GaugeWidget
tooltip={{
content: ({ variable, value, displayValue, unit }) => (
<div className="flex flex-col items-center">
<span className="font-semibold">{variable}</span>
<span>{displayValue}{unit ? ` ${unit}` : ""}</span>
<span className="text-xs opacity-70">raw: {value}</span>
</div>
),
}}
/>The tooltip follows the pointer and flips to the opposite side automatically once the pointer crosses the horizontal midpoint of the gauge, so it never overflows off the edge of a narrow container. It's hidden automatically while the widget is in a loading, error, or empty state.
Like every other part of the gauge, the tooltip is styleable via the tooltip slot in styles or a custom theme:
<GaugeWidget
styles={{
tooltip: "bg-slate-900 text-white text-xs px-2 py-1 rounded shadow-lg",
}}
/>animation controls the transition when the value (and, if enabled, the needle) moves to a new reading:
<AnedyaGauge animation={{ show: true, duration: 800, easing: "elasticOut" }} />| Prop | Type | Default | Description |
|---|---|---|---|
show |
boolean |
true |
Whether the bar (and needle, if also enabled) animates |
duration |
number |
1000 |
Transition duration in ms |
easing |
GaugeEasing |
"cubicOut" |
Any of the standard d3-ease families — easeLinear, easeQuadIn, easeQuadOut, easeQuadInOut, easeCubicIn, easeCubicOut, easeCubicInOut, easeSinIn, easeSinOut, easeSinInOut, easeExpIn, easeExpOut, easeExpInOut, easeCircleIn, easeCircleOut, easeCircleInOut, easeBackIn, easeBackOut, easeBackInOut, easeElasticIn, easeElasticOut, easeElasticInOut, easeBounceIn, easeBounceOut, easeBounceInOut |
With animation.show: false, both the bar and needle snap directly to the new value with no transition, regardless of needle.animation.
onDataChange works the same way it does on AnedyaCard (see onDataChange under AnedyaCard) — it's called with the latest fetched reading and a meta object, and can return a partial set of props (including styles) that temporarily override the gauge's own props:
Example 1 – change many props based on value ranges
<AnedyaGauge
node={node}
variable="pressure"
min={0}
max={100}
onDataChange={(data, meta) => {
if (!data || meta.kind !== "success") return;
if (data.value > 80) {
return {
title: "🔴 High",
color: "#ef4444",
theme: "dark",
arc: { startAngle: -135, endAngle: 135 },
needle: { type: "triangle", color: "#dc2626" },
animation: { duration: 500, easing: "elasticOut" },
tick: { show: true, color: "#fca5a5", labelColor: "#f87171" },
tooltip: {
content: ({ variable, displayValue, unit }) => (
<span className="text-red-400 font-semibold">
⚠ {variable}: {displayValue}{unit ? ` ${unit}` : ""} — critical
</span>
),
},
styles: {
value: "text-red-500 text-4xl font-black",
title: "uppercase tracking-wide",
},
};
}
if (data.value < 20) {
return {
title: "🟢 Low",
color: ["#10b981", "#34d399"],
arc: { thickness: 18 },
needle: { show: false },
tooltip: { show: false }, // low readings aren't worth hovering for
};
}
// Clear overrides for mid-range values — tooltip reverts to the default
}}
/>Example 2 – custom error and empty rendering
<AnedyaGauge
node={node}
variable="sensor"
onDataChange={(data, meta) => {
if (meta.kind === "error") {
return {
renderError: (msg) => (
<div className="flex flex-col items-center gap-2">
<span className="text-2xl">⚠️</span>
<span className="text-sm text-orange-600 italic">{msg}</span>
</div>
),
};
}
if (meta.kind === "empty") {
return {
renderEmpty: () => (
<span className="text-slate-400 animate-pulse">— waiting for first reading —</span>
),
};
}
// No override on success
}}
/>The only difference from Card is the data shape passed in — GaugeData ({ value, timestamp }) and GaugeDataMeta — but the resolution behavior (temporary override until the callback runs again; undefined/nothing clears it) is identical.
Rendering follows the same layered order as AnedyaCard:
- The props you pass to
<AnedyaGauge /> - Any temporary overrides returned from
onDataChange - Theme resolution (
"light","dark", or a customWidgetTheme<GaugeSlot>) - Per-slot class resolution
For each slot (container, title, unit, track, bar, needle, needleCap, value, label, tick, tickLabel, error, empty), classes are merged in this order:
GAUGE_DEFAULT_CLASSES- Active theme classes
styles
Later layers win whenever two Tailwind utilities conflict, resolved with twMerge. className is separate from this chain — merged onto the outer container after the container slot has been resolved.
Rule of thumb: defaults → theme →
styles→onDataChange. User overrides always win.
The sections below apply to every widget in this SDK. Where behavior is identical, only the shared explanation is given once; each subsection includes an example for both AnedyaCard and AnedyaGauge.
format, formatOptions, decimalPlaces, and labelFormat control what text is displayed.
styles controls how the widget looks.
onDataChange can override any of these (along with most other widget props), allowing the widget to react to incoming data.
For values that need unit-aware scaling (bytes, durations, lengths, etc.), pass a format preset instead of manually computing a unit string:
// AnedyaCard
<AnedyaCard node={node} variable="freeMemory" format="bytes" />
// renders "512" + "MB" — auto-scaled, no manual unit needed
<AnedyaCard node={node} variable="uptime" format="duration" />
// renders "2d 4h 12m"
<AnedyaCard node={node} variable="distance" format="length" formatOptions={{ formatOptions: 2 }} />
// renders "1.25" + "km"// AnedyaGauge
<AnedyaGauge node={node} variable="freeMemory" format="bytes" min={0} max={8_000_000_000} />
// renders "512" + "MB" on the value, and (if tick.show is on) scaled units on tick labels too
<AnedyaGauge node={node} variable="uptime" format="duration" min={0} max={86400} />
// renders "2d 4h 12m"Available presets:
| Preset | Input | Output example |
|---|---|---|
number |
any | "123,456" — locale-aware thousands separators |
bytes |
bytes | "500" + "KB" — auto-scales B/KB/MB/GB/TB (or KiB/MiB/GiB with formatOptions.binary: true) |
duration |
seconds | "2h 15m 30s" |
length |
meters | "1.25" + "km" — auto-scales mm/cm/m/km |
volume |
milliliters | auto-scales mL/L |
dataRate |
bits per second | auto-scales bps/Kbps/Mbps |
percent |
0–100 | "45%" |
formatOptions accepts:
{
locale?: string; // BCP 47 tag, e.g. "en-IN" — defaults to browser locale
binary?: boolean; // bytes only: 1024-based (KiB/MiB) vs 1000-based (KB/MB)
formatOptions?: number; // decimal places for the scaled number
}When to use each option:
locale— force a specific number format instead of relying on the visitor's browser/OS locale. Useful for a dashboard serving a specific region, or an app with its own explicit language setting:
<AnedyaCard format="number" formatOptions={{ locale: "en-IN" }} />
// "1,23,456" (Indian digit grouping) instead of "123,456"
<AnedyaGauge format="number" formatOptions={{ locale: "de-DE" }} min={0} max={200000} />
// "123.456" (German uses "." as the thousands separator, "," as the decimal point)formatOptions— control decimal places in the scaled output (not the raw value). Useful when the default rounding is too coarse for scientific/financial data, or you want whole numbers for a cleaner dashboard look:
<AnedyaCard format="bytes" formatOptions={{ formatOptions: 2 }} />
// "512.34" + "MB" — more decimal places than the default
<AnedyaGauge format="bytes" formatOptions={{ formatOptions: 0 }} min={0} max={1_000_000_000} />
// "512" + "MB" — whole number, no decimalsbinary— (bytespreset only) choose between 1000-based and 1024-based scaling. These genuinely produce different numbers for the same raw byte count, so picking the wrong one can make a value look incorrect even though the math is technically right for the mode you're in:
<AnedyaCard format="bytes" formatOptions={{ binary: true }} />
// raw 1048576 -> "1" + "MiB" — matches how OS file managers/RAM usage are usually reported
<AnedyaCard format="bytes" />
// raw 1048576 -> "1.05" + "MB" — matches how storage manufacturers/network specs are usually reportedAs a rule of thumb: use binary: true for memory/RAM/file-size-on-disk readings, and the default (decimal) for network throughput or storage capacity marketing figures.
format takes over the unit slot entirely. When set, the preset determines its own unit per value (e.g. switching from "KB" to "MB" as a byte count grows) — the unit prop is ignored while format is active. On AnedyaGauge, if tick.labelFormat isn't set, enabled tick labels also reuse the active format preset, so the value and the tick ring stay unit-consistent.
Presets don't validate that they match your data's actual meaning — they only know how to scale a raw number. Applying
format="length"to a Celsius reading, for example, will scale it as if it were meters and display a plausible-looking but semantically wrong unit. Only use a preset that matches what the underlying value actually represents.
formatValue always wins if provided — it's a full custom formatting function and bypasses format/decimalPlaces entirely:
// AnedyaCard
<AnedyaCard
formatValue={(v) => `${v.toFixed(2)} % RH`}
labelText={(ts) => `As of ${new Date(ts).toLocaleDateString()}`}
/>// AnedyaGauge
<AnedyaGauge
formatValue={(v) => `${v.toFixed(1)}%`}
labelText={(ts) => `As of ${new Date(ts).toLocaleDateString()}`}
/>Long
formatValueoutput (e.g."56.00 % RH") may wrap onto a second line at small widths — the value slot wraps rather than overflowing. OnAnedyaCardthis interacts with a fixedheight— see Sizing below. OnAnedyaGauge, the value block sits below (or centered over, in needle-less mode) the arc and wraps the same way within the available space.
By default, the label under the value shows a fixed clock time ("Updated 14:32:10"). This can be changed with labelFormat, or fully replaced with labelText.
<AnedyaCard {...commonProps} labelFormat="datetime" timezone="America/New_York" />
// commonProps = { node, variable: "humidity" }
// shows the timestamp converted to Eastern time, regardless of the visitor's own browser timezone
<AnedyaGauge {...commonProps} labelFormat="datetime" timezone="Asia/Kolkata" />
// commonProps = { node, variable: "humidity" }
// shows the timestamp converted to Indian Standard TimeIf omitted, the widget uses Intl.DateTimeFormat().resolvedOptions().timeZone — the browser's own detected timezone. Pass any IANA timezone name (e.g. "Asia/Kolkata", "Europe/London") to force a specific one instead — useful for dashboards where all viewers should see times in the device's local timezone rather than their own.
timezone affects the time, date, and datetime label presets. It has no effect on relative (a time difference is timezone-independent) or iso (ISO 8601 output is always UTC by definition).
+> In manual value mode (either widget, no node provided): there's no fetched timestamp to show. The label falls back to the moment the widget first mounted, so it still renders something meaningful instead of disappearing.
<AnedyaCard {...commonProps} labelFormat="relative" />
// commonProps = { node, variable: "humidity" }
// "5 minutes ago" instead of "Updated 14:32:10"
<AnedyaCard {...commonProps} labelFormat="date" />
// commonProps = { node, variable: "humidity" }
// "Updated 7/24/2026"
<AnedyaGauge {...commonProps} labelFormat="relative" />
// commonProps = { node, variable: "humidity" }
// "5 minutes ago" instead of "Updated 14:32:10"Available presets:
| Preset | Output example |
|---|---|
time (default) |
"Updated 14:32:10" |
date |
"Updated 7/24/2026" |
datetime |
"Updated 7/24/2026, 2:32:10 PM" |
relative |
"5 minutes ago" |
iso |
"2026-07-24T14:32:10.000Z" |
labelText always takes precedence over labelFormat and receives the raw timestamp, so you can use it for anything the presets don't cover — 12-hour/AM-PM formatting, hours-only, non-default locales, or combining relative time with custom surrounding text:
// AM/PM, 12-hour
<AnedyaCard
labelText={(ts) => {
const d = new Date(ts < 1e12 ? ts * 1000 : ts);
return `Updated ${d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true })}`;
}}
/>;
// "Updated 2:32 PM"
// Combining relative time with custom text
import { relativeTime } from "@anedyasystems/anedya-widgets-react/formatters";
<AnedyaCard labelText={(ts) => `Last synced ${relativeTime(ts)}`} />;
// "Last synced 5 minutes ago"
<AnedyaGauge labelText={(ts) => `Last synced ${relativeTime(ts)}`} />;
// "Last synced 5 minutes ago"labelFormat covers the common cases with zero code; labelText is the general escape hatch for everything else — the same relationship formatValue has with format.
Every widget distinguishes between three outcomes of a fetch, each rendered differently:
| State | When it happens | Default appearance |
|---|---|---|
| Success | Data was fetched normally | The formatted value, unit, and label |
| Error | The fetch failed (network error, bad request, etc.) | The error message, styled via the error slot |
| Empty | The fetch succeeded, but no data exists yet for this variable | "N/A", styled via the empty slot |
Both error and empty are real slots, so they pick up theme colors automatically and can be restyled the same way as any other slot:
<AnedyaCard
{...commonProps}
styles={{
error: "text-orange-500 italic",
empty: "text-slate-300",
}}
/>
<AnedyaGauge
{...commonProps}
styles={{
error: "text-orange-500 italic",
empty: "text-slate-300",
}}
/>
//commonProps = { node, variable: "humidity" }For full control beyond just restyling text, renderError and renderEmpty let you replace the entire error/empty state with your own JSX:
<AnedyaCard
{...commonProps}
renderError={(error) => (
<div className="flex flex-col items-center gap-1">
<span className="text-orange-500">⚠ Sensor unreachable</span>
<span className="text-xs text-slate-400">{error}</span>
</div>
)}
renderEmpty={() => <span className="text-slate-300">— no reading yet —</span>}
/>
<AnedyaGauge
{...commonProps}
renderError={(error) => (
<div className="flex flex-col items-center gap-1">
<span className="text-orange-500">⚠ Sensor unreachable</span>
<span className="text-xs text-slate-400">{error}</span>
</div>
)}
renderEmpty={() => <span className="text-slate-300">— no reading yet —</span>}
/>
// commonProps = { node, variable: "humidity" }renderError receives the raw error message string; renderEmpty takes no arguments. When either is provided, it fully replaces the default error/empty slot rendering — the corresponding styles.error/styles.empty classes are ignored in that case, since there's no default element left for them to apply to.
onDataChange receives a second argument describing which state just occurred, so you can react to errors or empty data the same way you already react to values:
<AnedyaCard
{...commonProps}
onDataChange={(data, meta) => {
if (meta.kind === "error") {
return { renderError: () => <span className="italic">Offline</span> };
}
if (meta.kind === "empty") {
return { renderEmpty: () => <span className="text-slate-300">Waiting for first reading…</span> };
}
if (!data) return;
if (data.value > 80) {
return { title: "High Humidity", theme: "dark" };
}
}}
/>
<AnedyaGauge
{...commonProps}
onDataChange={(data, meta) => {
if (meta.kind === "error") {
return { renderError: () => <span className="italic">Offline</span> };
}
if (meta.kind === "empty") {
return { renderEmpty: () => <span className="text-slate-300">Waiting for first reading…</span> };
}
if (!data) return;
if (data.value > 80) {
return { color: "#ef4444", theme: "dark" };
}
}}
/>
// commonProps = { node, variable: "humidity" }meta.kind is one of "success", "error", or "empty"; meta.error is present only when meta.kind === "error", carrying the underlying error message.
This is backwards compatible — existing onDataChange={(data) => {...}} callbacks that ignore the second argument continue to work unchanged.
The formatting helpers used internally are also available as a standalone import, for composing into your own labelText/formatValue functions — usable with either widget:
import { relativeTime } from "@anedyasystems/anedya-widgets-react/formatters";| Export | Signature | Description |
|---|---|---|
relativeTime |
(timestamp: number, locale?: string) => string |
Converts a timestamp into a human-relative string ("5 minutes ago"). Accepts seconds or milliseconds. |
width / height / minWidth / maxWidth are plain props, applied as inline styles on the container — independent of the whole styles/theme system:
<AnedyaCard width={320} height={200} minWidth={280} maxWidth={400} />Whether you pass height changes how the card behaves — this is intentional, not a quirk:
| Behavior | |
|---|---|
height omitted |
The card auto-grows to fit its content. If formatValue/labelText produce long text that wraps, the card simply becomes taller. Sizing scales with width only. |
height passed |
The card becomes a fixed-size box. Sizing scales with both width and height (see Responsive sizing). If content is too tall to fit (e.g. a long wrapped value at a small height), it is clipped, not overflowed — the card will not grow past the height you gave it. |
// Auto-height: card grows to fit content, whatever that content turns out to be
<AnedyaCard node={node} variable="humidity" />
// Fixed-height: card is exactly 300px tall; content that doesn't fit is clipped
<AnedyaCard node={node} variable="humidity" height={300} width={700} />If you're using a formatValue that can produce long strings, either don't pass a fixed height (let the card grow to fit), or pass a height generous enough for the wrapped text at your chosen width.
<AnedyaGauge width={280} height={200} />
<AnedyaGauge size={220} /> // shorthand: sets width AND height to the same value (a square gauge)size takes priority over width/height when both are set. Without size or height, the gauge fills its container's width and derives a proportional height automatically (unless aspectRatio is set).
| Prop | Type | Description |
|---|---|---|
size |
number |
Shorthand for equal width/height — a fixed square gauge |
width |
number |
Container width in px |
height |
number |
Container height in px |
minWidth |
number |
Minimum container width in px |
maxWidth |
number |
Maximum container width in px |
minHeight |
number |
Minimum container height in px |
maxHeight |
number |
Maximum container height in px |
aspectRatio |
number |
Width-to-height ratio used when neither size nor height is set |
Unlike Card, the gauge doesn't clip content past a fixed height — instead, the arc's radius and internal spacing shrink to fit whatever box is available (see Responsive sizing below).
The widget automatically scales its internal spacing and typography using CSS Container Queries — based on the widget's own rendered size, not the browser viewport.
By default the following properties scale automatically:
- container padding
- title font size
- value font size
- unit font size
- label font size
- spacing (
gap) - arc thickness (gauge bar thickness)
When height is omitted, scaling responds to the container's width only (cqw).
When height is passed, scaling responds to both width and height (cqw + cqh) — a taller fixed-size container renders bigger text/gaps than a shorter one at the same width, in addition to width's usual effect. For the AnedyaGauge, the arc radius and thickness are also computed from the available container size, so they resize automatically.
A widget inside a narrow dashboard column will use smaller typography than the same widget rendered full-width, even on the same screen — this behavior is built in and needs no configuration.
If you provide your own font-size utility for a slot, you're opting out of the default responsive sizing for that slot.
// AnedyaCard
<AnedyaCard
styles={{
value: "text-6xl",
}}
/>
// AnedyaGauge
<AnedyaGauge
styles={{
value: "text-6xl",
}}
/>The value will always render at text-6xl. Likewise, responsive Tailwind classes (text-2xl md:text-5xl) take precedence over the widget's default responsive sizing — your classes always win over the widget defaults.
AnedyaGauge note: the arc's radius itself is handled separately from these CSS variables (it's a geometry calculation, not a font size) — it auto-fits to whatever space is left after padding and, if enabled, tick-mark space is subtracted, capped by
arc.radiuswhen you set one explicitly.
While the initial fetch is in flight, the card renders skeleton loaders in place of the value and label — pulsing placeholder blocks sized to match each slot's current font-size variable (var(--anedya-card-value-size) / var(--anedya-card-label-size)), so the skeleton scales along with the card exactly like the real content would.
If the fetch fails or no data is available, the card renders the error message text in place of the value (title still renders normally).
Neither state currently accepts style overrides via styles — flag it if you need to customize their appearance.
While the initial fetch is in flight, the gauge renders the same kind of pulsing skeleton blocks in place of the value and label (sized against var(--anedya-gauge-value-size) / var(--anedya-gauge-label-size)), and the arc/needle themselves render at reduced opacity rather than disappearing, so the gauge's shape stays visible while data loads.
If the fetch fails or no data is available, the error message (or renderError/renderEmpty output) replaces the value/label block; the arc still renders behind it at the gauge's minimum value.
| Prop | Type | Description |
|---|---|---|
title |
string |
Card title. Default: "Latest Value" |
value |
number |
Manual/controlled value. Used as the initial value, or the only value if no node is given |
unit |
string |
Unit suffix shown next to the value |
decimalPlaces |
number |
Decimal places for the displayed value (used only if formatValue isn't provided) |
| Prop | Type | Description |
|---|---|---|
title |
string |
Gauge title. Default: "Latest Value" |
value |
number |
Manual/controlled value. Used as the initial value, or the only value if no node is given |
min |
number |
Minimum of the gauge's range. Default: 0 |
max |
number |
Maximum of the gauge's range. Default: 100 |
unit |
string |
Unit suffix shown next to the value |
decimalPlaces |
number |
Decimal places for the displayed value (used only if formatValue isn't provided) |
This SDK ships pre-compiled CSS alongside its JavaScript — you don't need Tailwind configured in your own project for these widgets to render correctly, even if you don't use Tailwind at all.
Import the stylesheet once, anywhere in your app's entry point (e.g. main.tsx, App.tsx, or your global styles file):
import "@anedyasystems/anedya-widgets-react/styles.css";That's the entire integration on your end. No content glob changes to your tailwind.config, no build coordination, nothing else required — every widget in this SDK will render fully styled as soon as this import is present.
If styles aren't appearing: double-check the import path matches your installed package name exactly, and that it's imported somewhere that actually runs before your widgets render (e.g. not inside a conditionally-loaded file). If you're using a bundler with strict CSS module resolution, confirm it supports subpath imports from
node_modules(most modern bundlers — Vite, Webpack 5+, Next.js — do, out of the box).
