Why SaaS dashboard habits fail on the plant floor
Most React dashboard templates are built to impress: a green arrow for growth, a coloured card per metric, charts that rescale and animate on every update. In a control room these habits do harm. An operator watches the screen for a whole shift, and anything that draws the eye for no reason wears down attention. If the running pump is green and the full tank is blue, the red of a real alarm has to compete with the whole screen.
The high-performance HMI approach, framed by ANSI/ISA-101.01-2015, takes the opposite view: calm in normal operation, so the abnormal stands out at once. The sections below translate that idea into React components and shadcn/ui tokens.
Status colours: grey first, colour for the abnormal
shadcn/ui already themes everything through CSS variables (background, card, muted, border…). Keep those for the neutral surface and add a small set of tokens whose only purpose is abnormal states:
| Token | Use | Never use it for |
|---|---|---|
alarm-1 to alarm-4 | alarm priorities, 1 critical to 4 low | decoration, "success" states |
quality | uncertain, bad or frozen data | process alarms |
process | running equipment, nominal values, as a dark neutral | anything that must catch the eye |
track, track-normal | indicator track and normal band |
Three rules make the scheme hold:
- Running is not green. A running pump is normal; normal is grey. Equipment state is carried by shape and text (a filled or outlined symbol, a "Running" label), not by colour.
- Quality has its own colour, different from every alarm colour, so a sensor fault is never mistaken for a process problem.
- Colour is never alone. Each alarm badge carries its priority number and a label, plus an
aria-label. Print the screen in greyscale: if you can no longer tell priorities apart, the coding is incomplete.
With Tailwind CSS 4, declare the tokens in @theme inline and define them under :root and .dark, as shadcn does for its own variables. A developer who wants a bright card "to make it look nice" then simply finds no token for it.
Values against their limits
"72.4 °C" does not say whether that is good. A measurement needs its context: range, LL/L/H/HH limits, setpoint. Model it once and pass it everywhere:
type Quality = "good" | "uncertain" | "bad" | "stale"
interface TagValue {
tag: string // "PT-101"
label: string
value: number | null // null when missing, never a fake 0
unit?: string
quality?: Quality
timestamp?: number // source time, not browser time
min?: number
max?: number
limits?: { ll?: number; l?: number; h?: number; hh?: number }
setpoint?: number
}
Evaluate the limits in a fixed order, so that high-high wins over high:
function limitState(v: number | null, lim?: TagValue["limits"]) {
if (v == null || !Number.isFinite(v) || !lim) return "normal"
if (lim.hh !== undefined && v >= lim.hh) return "highhigh"
if (lim.ll !== undefined && v <= lim.ll) return "lowlow"
if (lim.h !== undefined && v >= lim.h) return "high"
if (lim.l !== undefined && v <= lim.l) return "low"
return "normal"
}
A missing value returns "normal" on purpose: the problem is shown by the quality indicator, not by a false threshold alarm. Validate limits at configuration time (LL ≤ L ≤ H ≤ HH) rather than at render time.
On screen, prefer a horizontal analog bar to a round gauge: a grey track, a subtly darker normal band, limit ticks and a pointer. Several bars stacked vertically let the eye spot the one value out of line in a second.
Trends: fixed scale, no animation
shadcn/ui charts are built on Recharts, which helps: ChartContainer and ChartTooltip wrap standard Recharts components. Three settings matter for process trends:
- A fixed Y domain set by engineering, for example the instrument range. Autoscaling turns measurement noise into an apparent disaster. In Recharts, pass an explicit
domain={[min, max]}toYAxis. - No animation. Set
isAnimationActive={false}on each series. A trend that moves on every update makes the whole screen move. - Limits drawn on the chart. A
ReferenceLinefor H and HH, or aReferenceAreafor the normal band, shows how close the value is getting.
Avoid dual Y axes: two scales on one chart invite false correlations. Use small multiples with aligned time axes instead. Give the operator a pause button: a trend that keeps scrolling while someone tries to read a point is frustrating.
Alarms: states, not notifications
An alarm is not a toast. ISA-18.2 treats it as a state with two dimensions, active or not and acknowledged or not:
| Active | Acknowledged | Meaning | Display |
|---|---|---|---|
| yes | no | new, nobody has seen it | priority colour, slow blink |
| yes | yes | seen, the cause is still there | priority colour, steady |
| no | no | cleared before anyone saw it | stays in the list until acknowledged |
| no | yes | finished | leaves the list, remains in the event log |
Sort the list by state first (unacknowledged before acknowledged), then by priority, then by time. Acknowledgement must record who and when. Shelving (hiding an alarm until a set time) must be visible and time-limited, never a silent mute.
Measure the alarm rate as well. Figures commonly quoted from ISA-18.2 for one operator: about 1 alarm per 10 minutes on average is very likely acceptable, about 2 is the maximum manageable. A small "alarms per 10 minutes" block and a list of the most frequent sources (chattering alarms) tell the engineering team where to act.
Data quality: never lie about a value
A web dashboard adds failure modes a classic SCADA handles for you: a dropped WebSocket that leaves the last value on screen, a null rendered as 0, a NaN displayed as is. A value frozen by a lost link looks exactly like a stable one.
- Carry
qualityand a source timestamp with every value. The OPC UA status families Good, Uncertain and Bad map directly onto the first three states. - Detect stale data: if the timestamp has not moved for longer than the expected update period, mark the value as frozen, even if the connection claims to be open.
- Monitor the link with a heartbeat, not only with the age of the last value.
- Render a missing value as a dash, with the quality colour and a short reason, never as zero.
Real-time blocks that compute ages ("3 min ago") need a shared clock. Pass a now value from one hook to every block instead of calling Date.now() during render; it also avoids hydration warnings with Next.js server rendering.
Commands: hand the action back, show the result
Display components should not write to a PLC themselves. Expose callbacks (onAck, onApply, onCommand) and let your server or gateway perform the write with its own access control. In the interface, every command takes two steps (select, then confirm in a dialog that states what will happen), a setpoint is bounded before it is sent, and the screen shows the state returned by the PLC, not the command sent.
Ready-made blocks
The shadcn/ui industrial blocks apply these rules as 31 React blocks and 3 full screens (fleet, site, machine): threshold KPI cards, trends with limits, an ISA-18.2 alarm table with acknowledgement and shelving, alarm rate per 10 minutes, OEE, power flows. They install with the shadcn CLI into a React 19 and Tailwind CSS 4 project, in light and dark. They are interface components only: they do not connect to your PLCs, and they replace neither a control system nor your safety analysis. If your stack is Angular or plain HTML, the hmi-kit web components cover process graphics, trends, alarms and confirmed commands in the same style. For the underlying principles, see High-performance HMI: 8 rules for a web screen.
Sources
- ANSI/ISA-101.01-2015, Human Machine Interfaces for Process Automation Systems
- ANSI/ISA-18.2 and IEC 62682, management of alarm systems (not reproduced)
- Control Engineering, Implementing alarm management per the ANSI/ISA-18.2 standard (alarm rate benchmarks)
- OPC Foundation, OPC UA status codes (Good, Uncertain, Bad)