@devslab/numkey
v0.6.1
Published
Numeric input formatting — live thousands grouping, caret-safe editing, leading-zero cleanup, right alignment. String-first (money-safe), zero-dependency, works with plain <script>, Vue, and React.
Maintainers
Readme
numkey
English | 한국어 · 📺 Live demo
Try it online: ⚡ StackBlitz — Vanilla · Vue · React · Svelte · Solid | 📦 CodeSandbox
"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.
No build step (JSP, PHP, static pages)
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.)
How the attributes work
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); "3,2" for Indian lakh — see below |
| 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 |
| data-numkey-korean | live Korean amount reading ("150만") — see below |
| data-numkey-korean-entry | accept Korean shorthand entry ("3만5천" → blur → "35,000") — see below |
| data-numkey-name="amount" | hidden input posting the canonical value — see below |
| data-numkey-min / data-numkey-max | bounds, applied on blur only (typing is never interrupted) |
Locale-aware display (opt-in)
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 post through data-numkey-name (below) or normalize
server-side.
Indian lakh/crore grouping
The Indian system groups the last three digits and then every two —
12,34,56,789, not 123,456,789. Give group a [primary, secondary]
pair, or just name the locale:
<input data-numkey data-numkey-group="3,2"> <!-- 1,00,000 (1 lakh) -->
<input data-numkey data-numkey-locale="en-IN"> <!-- same, derived -->format('10000000', { group: [3, 2] }) // '1,00,00,000' (1 crore)
format('123456789', { locale: 'en-IN' }) // '12,34,56,789'An explicit group always wins over the locale, and a single number stays
uniform grouping (4 → 1234,5678 for 만-style). group: 0 turns
separators off entirely.
Korean amount reading (한글 금액 병기)
The "150만" hint that banking and fintech UIs render next to amount fields — usually rebuilt by hand in every project:
<input data-numkey data-numkey-korean>
<!-- typing 1500000 renders:
<input value="1,500,000"> <span class="numkey-korean">150만</span> -->
<input data-numkey data-numkey-korean="#my-hint"> <!-- use your own element -->An empty attribute generates a <span class="numkey-korean"> right after the
input (unstyled — add "원" with CSS .numkey-korean::after { content: " 원" });
a value is treated as a CSS selector for an existing element. The same engine
is available as a pure function:
import { toKorean } from '@devslab/numkey'
toKorean('1500000') // "150만"
toKorean('927483041001') // "9,274억 8,304만 1,001"
toKorean('100000001') // "1억 1" — zero groups omittedAnd the inverse — accept the shorthand people actually type into 부동산/주식 apps, either as a pure function or on the field itself:
import { fromKorean } from '@devslab/numkey'
fromKorean('3만5천') // "35000"
fromKorean('1.5억') // "150000000"
fromKorean('삼십오만') // "350000"<input data-numkey data-numkey-korean-entry>
<!-- typing 3만5천 is left alone (no live reformat fighting the IME);
blur converts it to 35,000 -->getValue and the data-numkey-name hidden sync (below) see the parsed
value even while the draft is still on screen.
Posting the canonical value (data-numkey-name)
A plain form POST submits what the field displays — 1,234,567 — which every
server handler then has to de-comma. data-numkey-name generates a hidden
input that always carries the settled canonical value instead:
<form method="post">
<!-- the visible input has NO name; the hidden one posts amount=1234567 -->
<input data-numkey data-numkey-name="amount" value="1234567">
</form>The hidden value is kept settled even mid-typing (1,234. posts 1234), so
whatever moment the form submits, the server sees a clean number.
Use
type="text"inputs. numkey setsinputmodeso mobile keyboards show the numeric keypad;type="number"has no caret API and fights formatting.
npm
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]Vue 3
<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>React
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" />Svelte
An action — and it imports nothing from svelte, so there is no peer
dependency at all. bind:value works: the action re-syncs the binding after
formatting, so the bound variable holds the display value. For the
canonical value use onValue.
<script>
import { numkey } from '@devslab/numkey/svelte'
let amount = '' // canonical: "1234567"
let display = '' // display: "1,234,567"
</script>
<input use:numkey={{ decimals: 2, onValue: (v) => (amount = v) }} />
<input use:numkey={2} bind:value={display} />
<input use:numkey /> <!-- options from data-numkey* -->Solid
A use: directive (reactive to a signal) and a ref factory. Solid delegates
input at the document level, so onInput already reads the formatted
value — no re-sync needed.
import { numkey, useNumkey } from '@devslab/numkey/solid'
const [amount, setAmount] = createSignal('') // canonical
<input use:numkey={{ decimals: 2, onValue: setAmount }} />
<input ref={useNumkey({ decimals: 2 })} value="1234567" />API
Options
| Option | Default | |
|---|---|---|
| decimals | 0 | max fraction digits (0 = integers only) |
| negative | false | allow a leading minus |
| group | 3 | digits per group — 4 for 만-style, [3, 2] for Indian lakh, 0 for none |
| 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. |
| min / max | — | bounds, applied on blur only — clamping mid-keystroke would make 50 untypeable in a min-10 field |
Core (pure functions)
| | |
|---|---|
| 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") |
| toKorean(canonical, opts?) | Korean amount reading ("1500000" → "150만") |
| fromKorean(text) | Korean shorthand → canonical ("3만5천" → "35000") |
DOM
| | |
|---|---|
| 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 |
Framework entry points
| | |
|---|---|
| @devslab/numkey/vue | NumkeyInput (v-model → canonical), vNumkey directive |
| @devslab/numkey/react | NumkeyInput (value/onValueChange → canonical), useNumkey |
| @devslab/numkey/svelte | use:numkey action (bind:value → display, onValue → canonical) |
| @devslab/numkey/solid | use:numkey directive, useNumkey ref factory |
Notes
- European formats work via options:
{ separator: '.', decimalPoint: ',' }displays1.234.567,89while the canonical value stays"1234567.89". - Accounting negatives paste correctly:
(1,234)— what Excel and most ERP exports produce — reads as-1234on anegativefield (currency signs may sit outside the parens). Parentheses that don't enclose digits, like(주)한국 1234, are left alone, and a positive-only field ignores the form entirely. - Backspace/Delete next to a group separator deletes the adjacent digit in one keystroke (the separator is skipped, and the reformat handles the rest).
Roadmap
- ~~
v0.2— Korean amount reading (150만) + hidden canonical sync~~ ✅ shipped - ~~
v0.3— Korean shorthand entry (3만5천→ 35,000)~~ ✅ shipped - ~~
v0.4— smart deletion across separators +min/max~~ ✅ shipped - ~~
v0.5— Svelte action / Solid directive~~ ✅ shipped - ~~
v0.6— Indian lakh/crore grouping (12,34,567) + locale-derived group sizes~~ ✅ shipped
Nothing else is planned — open an issue if your form needs something this doesn't do.
Contributing
Issues and PRs welcome — see CONTRIBUTING.md for the dev setup and the ground rules (string-first values, IME safety, char-by-char regression tests).
Family
- kokey — the keyboard-layout sibling in the "-key" input family: wrong-layout text restoration with a full Korean IME automaton
- More open source from devslab
License
MIT © devslab
