Mobile

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Check, Delete } from "lucide-react"
import { cn } from "@/lib/utils"

/** Hoisted through React 19's <style href precedence> — dedupes itself, no Tailwind config edit. */
const KEYFRAMES = `@keyframes zak-shake{0%,100%{transform:translateX(0)}25%{transform:translateX(-5px)}50%{transform:translateX(4px)}75%{transform:translateX(-2px)}}`

/** How long a finger rests on the delete key before it starts repeating (ms). */
const HOLD_DELAY_MS = 400
/** One deletion per this many ms for as long as the key is held. */
const REPEAT_MS = 80
/** Past this the grouped whole part would leave Number's exact range and the display would lie. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/amount-keypad.json

Prompt

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 input is 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 with formatToParts and 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.

On This Page