Skip to content
devslab-krPublic

About

Numeric input formatting β€” live thousands grouping, caret-safe editing, right alignment (숫자 인풋: μ²œλ‹¨μœ„ μ½€λ§ˆΒ·μ»€μ„œΒ·μ •λ ¬) β€” TypeScript, zero-dep, vanilla/Vue/React

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

numkey

numkey - a stable caret across grouped numeric units

Open source by DevsLab

npm CI TypeScript License Open in StackBlitz

English | ν•œκ΅­μ–΄ Β· πŸ“Ί Live demo

Try it online: ⚑ StackBlitz β€” Vanilla Β· Vue Β· React Β· Svelte Β· Solid | πŸ“¦ CodeSandbox

numkey demo β€” live thousands grouping, Korean amount reading (150만 원), paste sanitizing

"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,567 and 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 β€” Korean/Japanese IME), paste sanitizing (β‚© 1,234원 β†’ 1234)
  • Right alignment + inputmode set 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 omitted

And 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 sets inputmode so mobile keyboards show the numeric keypad; type="number" has no caret API and fights formatting.

npm

npm install @devslab/numkey
import { 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: ',' } displays 1.234.567,89 while the canonical value stays "1234567.89".
  • Accounting negatives paste correctly: (1,234) β€” what Excel and most ERP exports produce β€” reads as -1234 on a negative field (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.

Ideas we considered and why they are not here (in-field prefix/suffix, the unspecified Korean reading of negatives): docs/backlog.md.

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

About

Numeric input formatting β€” live thousands grouping, caret-safe editing, right alignment (숫자 인풋: μ²œλ‹¨μœ„ μ½€λ§ˆΒ·μ»€μ„œΒ·μ •λ ¬) β€” TypeScript, zero-dep, vanilla/Vue/React

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages