Amount Keypad
An in-app currency keypad that replaces the software keyboard — oversized live-grouped digits, decimal-point or cash-register entry, and a delete key that repeats on hold.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/amount-keypad.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "AmountKeypad" component (lucide-react for the
delete and confirm glyphs, Intl.NumberFormat for money, no other libraries).
It is a mobile component on purpose: it exists so the software keyboard never opens
on a payment screen, its delete key repeats on hold because touch has no OS key
repeat, and one variant sits on the bottom edge and pads the home indicator. On a
desktop this would just be a number field.
Contract
- Export a forwardRef div (role="group", aria-label from `label`, default
"Amount entry") extending HTMLAttributes minus onChange/onSubmit/defaultValue/children.
- Value props: value?/defaultValue? (controlled or uncontrolled) as a machine
string — digits, at most one "." separator, never grouped, never a symbol, ""
for untouched ("1234.5", "12.", ""). onChange?(value) fires on every accepted
press with that live string, trailing separator included. onSubmit?(value)
fires from the confirm key with a settled fixed-precision string ("12.00").
- Presentation: variant "stacked" | "sheet" | "compact" (default "stacked"),
mode "point" | "shift" (default "point"), showSubmit (default true),
safeArea (default true, read only by "sheet").
- Money: currency (ISO 4217, default "USD"), locale (BCP 47, default "en-US" —
a fixed default on purpose, since reading navigator.language during render
would disagree between server and client), fractionDigits (defaults to the
currency's own: 2 for USD, 0 for JPY; clamped 0..4), integerDigits (clamped
1..12, default 9 — past twelve digits the grouped whole part leaves Number's
exact range and the display starts lying).
- Limits and extras: max, min, presets (number[] quick-amount chips), hint
(one short line), labels (Partial of seven strings: max/min/precision/digits/
empty/clear/submit; max and min interpolate {amount}, precision {count}).
- Anything a parent hands in is sanitised for display only — digits kept, one
"." honoured, both halves clipped, a lone leading zero dropped — and the
cleaned string is never pushed back out through onChange, because that would
be a change the user never made. "," is deliberately NOT treated as a
separator on the way in: a grouped string ("1,234.50") must degrade to 1234.50,
not 1.23. The comma *key* is still mapped to the separator.
Behavior
- Layout is a 3x4 grid: 1-9, then [separator or 00] [0] [delete]. Every key is a
real <button type="button"> with an aria-label ("Digit 7", "Decimal separator",
"Double zero", "Delete last digit") and a 44px+ hit area (h-14, h-12 compact,
min-w-11).
- Accumulation, mode="point": type the whole part, press the separator, type the
minor units. A lone leading zero is replaced rather than appended (0 then 5 is
5), pressing the separator twice is a silent no-op (nothing went wrong), and
the separator key is not rendered at all when fractionDigits is 0 — that slot
becomes a 00 key, which is a real cash-register key rather than a dead one.
- Accumulation, mode="shift": the entry is minor units and every digit slides in
from the right, so 1 2 3 4 reads 12.34 and delete shifts it back the same way.
The 00 key takes this slot too. This is the model card readers use; it needs no
separator key at all.
- Live formatting cannot go through NumberFormat.format: a half-typed "12." has
no numeric equivalent, and formatting 12 would delete the separator the user
just pressed. Read the currency's shape once with formatToParts (prefix,
suffix, decimal separator, whether the locale groups at all), group only the
whole part with a second formatter, and append the typed separator and minor
units verbatim. This is what makes en-IN group 3;2;2 and de-DE print "1.234,50 €"
for free.
- Refuse, never clamp. A press that would pass `max`, run past integerDigits or
add a decimal past fractionDigits is turned down: the amount shakes once, the
hint line is replaced by the reason, and the entry is left exactly as it was.
Every accepted press clears the refusal. All presses — keys, 00, presets — go
through one gate, so no path can walk past `max`. A value that arrives from a
parent already above `max` shows the reason without a shake, since nobody
pressed anything.
- Hold-to-repeat delete: pointerdown deletes once immediately (that is what makes
it feel like a key rather than a button), then after 400ms it repeats every
80ms, and stops the instant the entry is empty rather than spinning under a
resting finger. The pointer is captured on the key that started it, so a finger
sliding off still delivers pointerup there and the repeat can never outlive the
press; pointercancel and lostpointercapture end it too. One pointerId owns the
hold — read and written in the same handler — so a second finger cannot start a
second interval. The key is touch-none (a held finger must not start a scroll
mid-repeat) with the iOS long-press callout suppressed.
- Because the first delete happens on pointerdown, the delete key must not also
act on click — except for the keyboard, whose synthesised click carries
detail === 0. That single check keeps Enter/Space on the key working without
double-deleting on a real press.
- Keyboard parity, handled on the root so events bubbling from any focused key
drive the same state machine: 0-9 append, "." / "," / the locale separator
press the separator key, Backspace deletes one (held down the OS repeats it by
itself — which is exactly why the touch key needed a repeat of its own),
Escape or Delete clears, Enter confirms unless the event target is a button
(Enter on a key is that key's own activation). Modified keystrokes
(meta/ctrl/alt) are ignored so browser shortcuts survive, and Escape is only
preventDefault + stopPropagation'ed when there is something to clear, so an
empty pad inside a sheet still lets Escape close the sheet.
- Confirm is aria-disabled — never natively disabled, because the user may be
standing on it — below `min` (or at zero when no min is given) and above `max`;
the click handler carries the same guard and turns a press into a spoken
refusal instead of silence. Quick-amount chips above `max` render aria-disabled
the same way and refuse with the max message when pressed.
- Cleanup: the hold timeout and the repeat interval are cleared on unmount, and
the pointer capture is released by the same handlers that end the hold. Nothing
else subscribes to anything.
Rendering & styling
- Semantic tokens only. Card: rounded-2xl border bg-card. Keys: bg-muted/60
rounded-lg with hover:bg-primary/10 and active:bg-primary/20 press tints
(primary-based on purpose: secondary/accent/muted share one value in the
default light palette and would be invisible against each other). Confirm is
the only high-priority surface and it INVERTS — bg-foreground text-background —
rather than taking a colour. Refusals are the one genuine semantic colour:
text-destructive. Focus ring: focus-visible:ring-2 ring-ring on every control.
- The amount is font-semibold tabular-nums tracking-tight and steps its type down
at 9 and 12 characters instead of widening the card, with min-w-0 + truncate as
the last resort. A fading caret bar sits after it — the cursor a field with no
keyboard would otherwise be missing.
- variant="stacked": hero amount centred over the grid, hint + Clear underneath,
full-width confirm at the bottom. variant="sheet": flat bottom, rounded top,
amount left with Clear right, a currency chip on the hint line, and
pb/pl/pr-[max(...,env(safe-area-inset-*))] so it can sit on the screen edge over
a home indicator. variant="compact": amount, Clear and an inline confirm pill on
one row over a tighter h-12 grid, for when the keypad shares a screen with a list.
- Motion is decoration only: the shake is a hoisted @keyframes in a React 19
<style href precedence="medium"> tag applied as [animation:...] with
motion-reduce:[animation:none], the press scale and the caret pulse both have
motion-reduce twins. Under reduced motion the refusal still prints its reason
and the caret still stands there — nothing is carried by movement alone.
- Accessibility: the visible amount is aria-hidden and one sr-only
role="status" aria-live="polite" region carries the fully formatted amount after
every accepted press, plus the refusal when there is one — a single region, so a
screen reader is never told the same thing twice. The hint line is wired to the
root with aria-describedby. The grid is its own role="group" labelled "Amount
keys". Merge the consumer className with cn().
- Restarting the shake is a key on the amount wrapper (a second refusal never
clears the first, so the class alone would not replay). That wrapper holds
nothing focusable, so its remount can never drop focus onto <body>.
Customization levers
- Layout: variant is the first knob — stacked for a whole screen, sheet for the
bottom edge, compact when a list shares the screen. Key size (h-14 / h-12) and
the grid gap scale together; keep the hit area at 44px or more.
- Money: currency + locale do all the formatting; fractionDigits overrides the
currency's own precision (raise it to 3 for fuel prices, drop it to 0 for a
no-cents kiosk), integerDigits caps how long an amount gets. Locales whose
numbering system is not Latin need -u-nu-latn on the tag, since the keys print
ASCII digits.
- Entry model: mode="shift" for card readers and till-style entry, mode="point"
for consumer payments. Switching it also swaps the separator key for a 00 key.
- Chrome: drop showSubmit when a screen-level button already confirms, drop
presets for a bare pad, pass hint for balances and card tails, and replace the
Clear button with your own if it belongs somewhere else on the screen.
- Limits: max refuses, min gates the confirm key; both messages are templates in
`labels` with {amount} / {count} tokens, so the whole widget translates from one
object.
- Physics: the 400ms hold delay and 80ms repeat interval are the two numbers that
decide how the delete key feels; slow the interval for long amounts, raise the
delay if your users rest fingers on keys.Concepts
- Keypad instead of keyboard — the whole point is that no
inputis focused and no software keyboard opens, so the amount keeps its half of the screen and the separator is the locale's rather than whichever one the OS keyboard happens to offer. The cost is that every affordance a text field gives you for free — caret, delete repeat, paste — has to be built, which is most of this component. - Live grouping, not format-on-blur — a text field can wait for blur to normalise because it owns a caret; a keypad has no caret to fight, so it reformats on every press. A half-typed
12.has no numeric equivalent, so the currency's shape is read once withformatToPartsand the string is rebuilt around the digits actually typed. - Hold-to-repeat is the touch stand-in for OS key repeat — a physical Backspace repeats because the operating system says so; a finger on glass gets nothing, so the repeat is timed by hand (one delete on press, then every 80ms after 400ms) and stops itself the moment there is nothing left to delete.
- Refuse, never clamp — silently clamping an over-limit press makes the pad look broken; refusing it shakes the amount once, says why on the hint line and leaves the entry untouched, so the user knows a press was heard and rejected. Under reduced motion the sentence carries it alone.
- Two accumulation models — point entry (type, press the separator, type minor units) is what consumers expect; shift entry (every digit slides in from the right, 1 2 3 4 is 12.34) is what card readers do. Same keys, same state, different rule for where a digit lands — and where there are no minor units at all, the separator key becomes a 00 key rather than a dead one.
- The bottom edge is part of the contract — the sheet variant is meant to sit on the screen edge, so its bottom and side padding go through
env(safe-area-inset-*); without that the confirm key ends up under the home indicator on exactly the phones this component was drawn for.
Swipe To Reply
A chat row whose sideways swipe arms a reply — the row tracks the thumb, a mark fills the gutter behind it, and past the threshold releasing commits and springs the row straight back.
Keyboard Accessory
The bar that rides on top of the software keyboard: previous / next field travel, quick-insert chips, and a Done key that hands focus back on purpose.