"It's a string, but it's a number." Every business app has these fields —
amounts, quantities, prices — and every team rebuilds the same input by hand:
live thousands grouping (10,000), no leading zeros, right-aligned, numeric
keypad on mobile, and a caret that doesn't jump while separators appear and
disappear around it.
numkey is that input, done once:
- Caret-safe live formatting — type into the middle of
1,234,567and the cursor stays where you expect, even as groups reflow - String-first value model — the canonical value is a plain numeric string
(
"1234567.89"), never an IEEE 754 float, so money is safe - Leading-zero cleanup (
007→7), full-width digit normalization (123→123— Korean/Japanese IME), paste sanitizing (₩ 1,234원→1234) - Right alignment +
inputmodeset automatically (opt-out available) - IME-safe — nothing runs mid-composition
- Zero dependencies, TypeScript-first, ESM/CJS dual + a CDN global build
- Works with plain
<script>(JSP/PHP/anything server-rendered), Vue 3, and React
Wrong-keyboard-layout text in the same form? That's numkey's sibling, kokey.
One script tag; everything else is markup. Inputs are auto-bound, including ones added to the DOM later.
<script src="https://cdn.jsdelivr.net/npm/@devslab/numkey"></script>
<input data-numkey> <!-- integers: 1,234,567 -->
<input data-numkey="2"> <!-- 2 decimals: 1,234.56 -->
<input data-numkey data-numkey-negative> <!-- minus allowed -->
<input data-numkey data-numkey-align="left"> <!-- keep left alignment -->Server-rendered values (<input data-numkey value="1234567">) are formatted
on load. To read the raw value back before submitting:
<script>
const raw = numkey.getValue(document.querySelector('#amount')) // "1234567"
</script>(Or simply strip separators server-side — the posted value is the display value.)
data-numkey is the on-switch. It is what gets an input bound (auto-init
watches input[data-numkey]), and its value doubles as the max decimal
places — empty means integers only. Every other data-numkey-* attribute is
an option that is only read from inputs that have data-numkey; on its own
it does nothing:
<input data-numkey> <!-- ON, integers: 1,234,567 -->
<input data-numkey="2"> <!-- ON, 2 decimals: 1,234.56 -->
<input data-numkey="2" data-numkey-negative> <!-- options stack -->
<input data-numkey-locale="auto"> <!-- ✗ does NOTHING — no data-numkey -->
<input> <!-- plain input, untouched -->| Attribute | Meaning |
|---|---|
data-numkey |
the switch — binds the input; the value is the max decimal places (empty = integer) |
data-numkey-negative |
allow a leading minus |
data-numkey-align="left" |
opt out of automatic right alignment |
data-numkey-group="4" |
group size (default 3) |
data-numkey-separator=" " |
group separator (default ,) |
data-numkey-point="," |
decimal mark shown in the field (default .) |
data-numkey-locale |
derive separators from a locale — see below |
By default the display is deterministic: every visitor sees
1,234,567.89, whatever their browser is set to — which is what business
forms usually need. data-numkey-locale opts a field into locale separators:
<!-- everyone sees 1,234,567.89 — the default, no locale involved -->
<input data-numkey="2">
<!-- follows the visitor's browser language:
a German browser shows 1.234.567,89
a Korean browser shows 1,234,567.89 -->
<input data-numkey="2" data-numkey-locale="auto">
<!-- pinned to German formatting for every visitor -->
<input data-numkey="2" data-numkey-locale="de-DE">The locale changes only how the value is drawn. The canonical value is
always "1234567.89" — numkey.getValue(el) returns the same string in all
three cases. Since a plain form POST submits the display value, a form
using locales should read getValue into a hidden field (or normalize
server-side) before submitting.
Use
type="text"inputs. numkey setsinputmodeso mobile keyboards show the numeric keypad;type="number"has no caret API and fights formatting.
npm install @devslab/numkeyimport { format, parse, bind, observe } from '@devslab/numkey'
format('1234567.5', { decimals: 2 }) // "1,234,567.5"
parse('₩ 1,234,567원') // "1234567"
bind(document.querySelector('#amount'), { decimals: 2 }) // one element
observe() // all [data-numkey]<script setup>
import { ref } from 'vue'
import { NumkeyInput, vNumkey } from '@devslab/numkey/vue'
const amount = ref('') // always canonical: "1234567"
</script>
<template>
<!-- v-model gets the canonical value; the field shows 1,234,567 -->
<NumkeyInput v-model="amount" :decimals="2" negative />
<!-- or the directive for plain inputs -->
<input v-numkey="2">
</template>import { NumkeyInput, useNumkey } from '@devslab/numkey/react'
// Controlled: value/onValueChange speak canonical strings
const [amount, setAmount] = useState('')
<NumkeyInput value={amount} onValueChange={setAmount} decimals={2} negative />
// Uncontrolled: ref-callback hook
<input ref={useNumkey({ decimals: 2 })} defaultValue="1234567" />| Option | Default | |
|---|---|---|
decimals |
0 |
max fraction digits (0 = integers only) |
negative |
false |
allow a leading minus |
group |
3 |
digits per group (4 for 만-style grouping) |
separator |
"," |
group separator in the display |
decimalPoint |
"." |
decimal mark in the display (canonical always uses .) |
locale |
— | opt-in: derive separator/decimalPoint via Intl — "auto" (browser language) or a BCP 47 tag. Without it the display is deterministic no matter the visitor's browser, which is what business forms usually need. Explicit separator/decimalPoint win. |
parse(display, opts?) |
display/paste mess → canonical "1234567.89" |
format(canonical, opts?) |
canonical → display "1,234,567.89" |
finalize(canonical) |
settle transient typing states ("1234." → "1234") |
bind(el, opts?) |
attach live formatting; returns an unbind function |
observe(root?) |
bind all [data-numkey] now and as they appear |
getValue(el, opts?) |
canonical value of a bound input |
setValue(el, canonical, opts?) |
write a canonical value as the formatted display |
applyToInput(el, opts?) |
one caret-preserving reformat (building block) |
createRefBinder(opts?) |
ref-callback factory for any framework |
- European formats work via options:
{ separator: '.', decimalPoint: ',' }displays1.234.567,89while the canonical value stays"1234567.89". - Backspacing directly over a separator moves the caret past it (the digit is deleted on the next backspace) — the same behavior as the major masking libraries. Smart separator-skipping deletion is on the roadmap.
- Roadmap: Korean unit reading (
1500000→ “150만”), 만/억 shorthand parsing (3만5천→35000), hidden-field canonical sync for classic form posts.
MIT © devslab