Skip to content
Nana UI

Rental

Meter Input

A utility-meter reading field for rentals. Shows the previous reading beside the box, refuses a reading that runs backwards, and explains a consumption figure that does not look like the usual one for the meter.

View .md Download

Reading a water or electricity meter is three jobs at once: remember what the dial said last time, refuse a number that cannot be right, and say something useful when the number is possible but surprising. The meter input does all three in one field.

The meter input ships for all three targets. Pick the tab for your project — the CLI detects which you are on and serves the matching implementation.

npx @nana-tec/ui add meter-input

Lands as src/components/ui/meter-input/, with reading.ts beside the component — the arithmetic and the wording live there, so you can reuse them in a server-side check without importing the field.

import { MeterInput } from "@/components/ui/meter-input";
export function WaterReading() {
return (
<MeterInput
label="Water reading"
unit="m³"
previous={1240}
previousDate="2026-08-03"
average={18}
required
onReadingChange={(reading) => console.log(reading.consumption, reading.anomaly)}
/>
);
}

The field is uncontrolled until you pass value. onReadingChange hands you the whole verdict — reading, consumption, ratio, anomaly, error and explanation — so a form can store the consumption it just worked out rather than recomputing it.

A reading is refused — the field goes invalid and the form will not submit — only for the three things that cannot be true:

CaseMessage
Required and blankEnter the current reading.
Not a numberEnter the reading as a number.
Below the previous readingA reading cannot be lower than the previous one (1,240 m³).

Readings are typed on site, so spaces inside the number are ignored: 1 240 is 1240. Commas are not — they mean a decimal point in some places and a thousands separator in others, and guessing would turn 1,5 into fifteen.

The error waits until the field is left — half a reading is not a mistake yet. A form that validates on submit sets touched to reveal the message early, which is also what the example below does.

Set average to the consumption a period on this meter normally comes to, and the field explains any reading that lands well outside it. tolerance sets the band — the default 2 flags anything at twice the usual or half of it. A tolerance of 1 or less would flag everything, so it falls back to the default.

An anomaly is a warning, not an error: an unusual month is still a real month, so the field stays valid and submittable while it says why the number stands out.

Three cases are worded, and each says what to do about it:

AnomalyWhenReads as
highConsumption at or above average × tolerance90 m³ is 5× the usual 18 m³ on this meter. Check the digits before saving.
lowConsumption at or below average ÷ tolerance3 m³ is 17% of the usual 18 m³ on this meter. Check the digits before saving.
flatNothing used since the last readingNothing has been used since the reading on 3 Aug. Check that the meter is running and the unit is occupied.

Your own wording replaces the generated line — a slot on the Web Component, a prop on the framework builds — which is where a rental system puts what it knows about the unit:

Unit 4B has a garden tap — check the outside meter before billing.

With no average there is nothing to compare against, so nothing is flagged except a meter that has not moved at all — which is worth saying whether or not you know the usual figure.

The React and Svelte props, read from the components’ own types:

MeterInput

PropTypeDefault
sizeMeterInputSize'md'
labelReactNode'Current reading'
previous
The reading this period is measured from.
number
previousDate
ISO date the previous reading was taken, shown as context.
string
unit
What the meter counts in — `kWh`, `m³`, `units`.
string''
average
Consumption a period on this meter normally comes to.
number
tolerance
How many times the average reads as high; its reciprocal reads as low.
number2
helperTextReactNode
explanation
Replaces the generated anomaly wording.
ReactNode
touched
Whether the validation message is showing yet. The field decides this itself once the reading has been left; a form that validates on submit can pass `true` to reveal the message early.
boolean
valuestring
defaultValuestring''
onReadingChange(reading: MeterReading) => void

Also accepts Omit<InputHTMLAttributes<HTMLInputElement>, 'size' | 'value' | 'defaultValue'>.

The attribute names, events and CSS hooks below are the Web Components reference.

Properties

PropertyAttributeTypeDefaultDescription
averageaveragenumber | undefined—Consumption a period on this meter normally comes to
disableddisabledbooleanfalse
helperTexthelper-textstring''Guidance shown under the field while nothing else needs saying
inputIdinputId——
labellabelstring'Current reading'Field label
namenamestring''
placeholderplaceholderstring''
previouspreviousnumber | undefined—The reading this period is measured from
previousDateprevious-datestring''ISO date the previous reading was taken, shown as context
readonlyreadonlybooleanfalse
requiredrequiredbooleanfalse
sizesizeMeterInputSize'md'
tolerancetolerancenumber2How many times the average reads as high; its reciprocal reads as low
touchedtouchedbooleanfalseWhether the validation message is showing yet. The field sets this itself once the reading has been left — half a reading is not a mistake — and a form that validates on submit can set it to reveal the message early.
unitunitstring''What the meter counts in — kWh, m³, units
valuevaluestring''The reading being entered

Events

EventDescription
nana-inputFired on every keystroke (detail.value)
nana-changeFired when the reading is committed (detail.value)
nana-focusFired when the field gains focus
nana-blurFired when the field loses focus
nana-anomalyFired when the reading starts or stops looking wrong (detail.anomaly, detail.consumption, detail.ratio)

Slots

SlotDescription
explanationReplaces the generated anomaly wording
suffixContent after the field, in place of the unit label

CSS parts

PartDescription
::part(base)The field container
::part(previous)The previous-reading line above the field
::part(wrapper)The bordered field container
::part(input)The native <input> element
::part(unit)The unit label inside the field
::part(consumption)The consumption readout below the field
::part(explanation)The anomaly explanation
::part(error)The validation message

CSS custom properties

PropertyDescription
--nana-input-bgField background colour, shared with the other form controls
--nana-input-colorField text colour, shared with the other form controls
--nana-input-heightField height, shared with the other form controls
--nana-input-radiusCorner radius, shared with the other form controls
EventTypeDescription
nana-inputCustomEventFired on every keystroke (detail.value)
nana-changeCustomEventFired when the reading is committed (detail.value)
nana-anomalyCustomEventFired when the reading starts or stops looking wrong (detail.anomaly, detail.consumption, detail.ratio)
nana-focusCustomEventFired on focus
nana-blurCustomEventFired on blur
SlotDescription
explanationReplaces the generated anomaly wording
suffixContent after the field, in place of the unit label
PartDescription
baseThe field container
previousThe previous-reading line above the field
wrapperThe bordered field container
inputThe native <input> element
unitThe unit label inside the field
consumptionThe consumption readout below the field
explanationThe anomaly explanation
errorThe validation message

The previous reading is tied to the field with aria-describedby, so a screen reader hears what the number is measured from before it is typed. The explanation is a role="status" region — it announces politely when it appears, because it is advice, not a failure — while the validation message is a role="alert".

Sizes follow the same ramp as Input, Select and Combobox, so a meter reading sits on the same line as the rest of a form without adjustment.

Explore Meter Input interactively Every state, prop, and edge case — with live controls — in Storybook.
View in Storybook