Money Input
A currency amount field built on the shadcn Input. It reads and writes minor units — integer cents — so callers never handle floating-point money.
'use client'
import * as React from 'react'
import { MoneyInput } from '@/components/custom/money-input'
import { formatMoney } from '@/lib/kiota-format'
export default function MoneyInputDemo() {
const [amount, setAmount] = React.useState<number | null>(125050)
return (
<div className="w-full max-w-sm space-y-3">
<MoneyInput value={amount} onValueChange={setAmount} placeholder="0.00" />
<p className="text-sm text-muted-foreground">
Stored value: <code className="font-mono">{amount === null ? 'null' : amount}</code> minor units
{amount !== null ? ` (${formatMoney(amount)})` : ''}
</p>
</div>
)
}Installation
pnpm dlx shadcn@latest add https://kiotapay-ui-staging.staging.kiotapay.com/r/money-input.jsonnpx shadcn@latest add https://kiotapay-ui-staging.staging.kiotapay.com/r/money-input.jsonyarn dlx shadcn@latest add https://kiotapay-ui-staging.staging.kiotapay.com/r/money-input.jsonbunx --bun shadcn@latest add https://kiotapay-ui-staging.staging.kiotapay.com/r/money-input.jsonInstalls components/custom/money-input.tsx, plus the shadcn input primitive and kiota-theme if you do not already have them.
Usage
'use client'
import * as React from 'react'
import { MoneyInput } from '@/components/custom/money-input'
export function AmountField() {
const [amount, setAmount] = React.useState<number | null>(null)
return <MoneyInput value={amount} onValueChange={setAmount} placeholder="0.00" />
}Why minor units
0.1 + 0.2 is 0.30000000000000004. That is not a quirk to work around; it is what binary floating point does to decimal fractions, and it compounds across a column of totals until a reconciliation report is off by a cent and nobody can say where.
Integers do not have that failure mode. 1230 is exactly 1230, and KSh 12.30 is what you render at the edge. So the component's value and onValueChange both speak minor units, and conversion happens only at display time.
import { formatMoney, toMinorUnits, fromMinorUnits } from '@/lib/kiota-format'
formatMoney(125050) // "KSh 1,250.50"
formatMoney(125050, { currency: 'USD', locale: 'en-US' }) // "$1,250.50"
toMinorUnits(1250.5) // 125050
fromMinorUnits(125050) // 1250.5Precision is resolved from CLDR at runtime rather than a hardcoded table, so it stays correct per currency: KES and USD get two decimals, JPY and UGX get none, KWD gets three.
Props
Extends React.ComponentProps<'input'> minus value, defaultValue, onChange and type.
| Prop | Type | Default | Description |
|---|---|---|---|
value | number | null | — | Amount in minor units. null renders an empty field. |
onValueChange | (minorUnits: number | null) => void | — | Fires on every keystroke with the parsed amount. |
currency | string | 'KES' | ISO 4217 code. Drives symbol, grouping and precision. |
locale | string | 'en-KE' | BCP 47 tag used for grouping and symbol form. |
showSymbol | boolean | true | Render the currency symbol inside the field. |
align | 'left' | 'right' | 'left' | Text alignment. Use 'right' inside tables. |
Examples
Multiple currencies
Precision follows the currency without any configuration — yen has no decimal place to type into.
'use client'
import * as React from 'react'
import { MoneyInput } from '@/components/custom/money-input'
import { Label } from '@/components/ui/label'
export default function MoneyInputCurrencies() {
const [kes, setKes] = React.useState<number | null>(45000)
const [usd, setUsd] = React.useState<number | null>(34999)
const [jpy, setJpy] = React.useState<number | null>(52000)
return (
<div className="grid w-full max-w-md gap-4">
<div className="grid gap-2">
<Label htmlFor="kes">Kenyan shilling — 2 decimals</Label>
<MoneyInput id="kes" currency="KES" locale="en-KE" value={kes} onValueChange={setKes} />
</div>
<div className="grid gap-2">
<Label htmlFor="usd">US dollar — 2 decimals</Label>
<MoneyInput id="usd" currency="USD" locale="en-US" value={usd} onValueChange={setUsd} />
</div>
<div className="grid gap-2">
<Label htmlFor="jpy">Japanese yen — no decimals</Label>
<MoneyInput id="jpy" currency="JPY" locale="ja-JP" value={jpy} onValueChange={setJpy} />
</div>
</div>
)
}In a form
Validation compares minor units directly, so there is no rounding step between what the user typed and what you check.
'use client'
import * as React from 'react'
import { MoneyInput } from '@/components/custom/money-input'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { formatMoney } from '@/lib/kiota-format'
const MINIMUM = 5000 // KSh 50.00, in minor units
export default function MoneyInputForm() {
const [amount, setAmount] = React.useState<number | null>(null)
const [submitted, setSubmitted] = React.useState<number | null>(null)
const tooSmall = amount !== null && amount < MINIMUM
const invalid = amount === null || tooSmall
function handleSubmit(event: React.FormEvent) {
event.preventDefault()
if (!invalid) setSubmitted(amount)
}
return (
<form onSubmit={handleSubmit} className="w-full max-w-sm space-y-4">
<div className="grid gap-2">
<Label htmlFor="payout">Payout amount</Label>
<MoneyInput
id="payout"
value={amount}
onValueChange={setAmount}
align="right"
placeholder="0.00"
aria-invalid={tooSmall}
aria-describedby="payout-hint"
/>
<p id="payout-hint" className={tooSmall ? 'text-sm text-destructive' : 'text-sm text-muted-foreground'}>
{tooSmall ? `Minimum payout is ${formatMoney(MINIMUM)}.` : `Minimum ${formatMoney(MINIMUM)}.`}
</p>
</div>
<Button type="submit" disabled={invalid}>
Request payout
</Button>
{submitted !== null ? (
<p className="text-sm text-muted-foreground">
Submitted <code className="font-mono">{submitted}</code> — {formatMoney(submitted)}
</p>
) : null}
</form>
)
}Behaviour notes
Focused and blurred states differ deliberately. While focused the field shows a plain editable number; on blur it reformats with locale grouping. Editing a grouped string directly is the standard failure mode for currency inputs — the caret jumps whenever a separator is inserted mid-word — so the editable and display representations are kept separate.
Input parsing is permissive. 1,234.56 and 1.234,56 both parse: the last separator is treated as the decimal mark, which is correct under either convention. Unparseable input yields null rather than 0, so an empty field is distinguishable from a deliberate zero.
The symbol is decorative. It carries aria-hidden, and its width is measured in ch so KSh and $ both align without a per-currency lookup. Pair the field with a <Label> that names the currency when it matters.