Angular Numeric Input
Numora is a precision-first Angular numeric input library: thousand separators, decimal limits, paste sanitisation, scientific-notation expansion and cursor preservation – wrapped in a standalone directive that maps cleanly onto ngOnInit / ngOnDestroy. Because Numora's value is a string end-to-end, the directive composes with ControlValueAccessor and FormControl<string> without any number-coercion gymnastics – ideal for currency and DeFi flows that cannot tolerate IEEE 754 rounding. No numora-angular wrapper, 6.4 kb gzipped, zero runtime dependencies.
Install
pnpm add numora
# or
npm install numoraStandalone directive with ControlValueAccessor
NumoraInput attaches directly to an existing <input> element. Implementing Angular's ControlValueAccessor on the directive lets the same <input numora> plug into [(ngModel)] and Reactive Forms with no extra glue:
src/app/numora.directive.ts:
import {
Directive,
ElementRef,
forwardRef,
HostListener,
inject,
Input,
OnInit,
} from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { NumoraInput, type NumoraInputOptions } from 'numora';
@Directive({
selector: 'input[numora]',
standalone: true,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => NumoraDirective),
multi: true,
},
],
})
export class NumoraDirective implements ControlValueAccessor, OnInit {
private readonly el = inject<ElementRef<HTMLInputElement>>(ElementRef);
private instance!: NumoraInput;
private pendingValue: string | null = null;
private onChangeCb: (value: string) => void = () => {};
private onTouchedCb: () => void = () => {};
@Input('numora') options: NumoraInputOptions = {};
ngOnInit(): void {
this.instance = new NumoraInput(this.el.nativeElement, {
...this.options,
onChange: (raw: string) => {
this.onChangeCb(raw);
this.options.onChange?.(raw);
},
});
if (this.pendingValue !== null) {
this.instance.setValue(this.pendingValue);
this.pendingValue = null;
}
}
@HostListener('blur')
onBlur(): void {
this.onTouchedCb();
}
writeValue(value: string | null): void {
const next = value ?? '';
if (this.instance) {
this.instance.setValue(next);
} else {
this.pendingValue = next;
}
}
registerOnChange(fn: (value: string) => void): void {
this.onChangeCb = fn;
}
registerOnTouched(fn: () => void): void {
this.onTouchedCb = fn;
}
setDisabledState(isDisabled: boolean): void {
if (!this.instance) return;
isDisabled ? this.instance.disable() : this.instance.enable();
}
}Use it directly on any <input>:
import { Component, signal } from '@angular/core';
import { NumoraDirective } from './numora.directive';
import { ThousandStyle } from 'numora';
@Component({
selector: 'app-amount',
standalone: true,
imports: [NumoraDirective],
template: `
<label>
Amount
<input
[numora]="{
thousandStyle: ThousandStyle.Thousand,
decimalMaxLength: 2,
onChange: handleChange,
}"
/>
</label>
<p>Raw value: {{ value() }}</p>
`,
})
export class AmountComponent {
protected readonly ThousandStyle = ThousandStyle;
protected readonly value = signal('');
protected readonly handleChange = (v: string) => this.value.set(v);
}Or plug it into Reactive Forms – the raw string flows through FormControl:
import { Component } from '@angular/core';
import { ReactiveFormsModule, FormControl } from '@angular/forms';
import { NumoraDirective } from './numora.directive';
import { ThousandStyle } from 'numora';
@Component({
selector: 'app-amount-form',
standalone: true,
imports: [NumoraDirective, ReactiveFormsModule],
template: `
<label>
Amount
<input
[formControl]="amount"
[numora]="{ thousandStyle: ThousandStyle.Thousand, decimalMaxLength: 2 }"
/>
</label>
<p>Raw value: {{ amount.value }}</p>
`,
})
export class AmountFormComponent {
protected readonly ThousandStyle = ThousandStyle;
protected readonly amount = new FormControl('0', { nonNullable: true });
}Numora adopts the <input> and forces the required attributes (type, inputmode, spellcheck, autocomplete, pattern). The ControlValueAccessor bridge means writeValue calls Numora's setValue, registerOnChange wires the raw string back into the form control, and setDisabledState toggles Numora's disable() / enable() – everything Angular Forms expects.
Because the <input> is yours, set placeholder, aria-label, name or any other attribute on it directly in the template.
Animated example: Numora + Torph in Angular
For animated digit transitions, stack a Torph TextMorph on top of a transparent-text NumoraInput. The input owns the keyboard, undo and IME; Torph animates the visible characters. The vanilla overlay guide covers the pattern in depth - below is the idiomatic Angular standalone component.
pnpm add numora torphimport {
Component,
ElementRef,
OnDestroy,
OnInit,
ViewChild,
ViewEncapsulation,
} from '@angular/core';
import { NumoraInput, FormatOn, ThousandStyle } from 'numora';
import { TextMorph } from 'torph';
@Component({
selector: 'app-numora-overlay',
standalone: true,
encapsulation: ViewEncapsulation.None, // Numora's <input> is created at runtime
template: `
<label class="numora-overlay">
<span #display class="numora-overlay-display" aria-hidden="true">0</span>
<div #host class="numora-overlay-host"></div>
</label>
`,
styles: [`
.numora-overlay {
position: relative;
display: inline-flex;
align-items: center;
font: 2.25rem/1 ui-monospace, SFMono-Regular, monospace;
color: white;
}
.numora-overlay-display { pointer-events: none; white-space: pre; }
.numora-overlay-host { position: absolute; inset: 0; }
.numora-overlay-host input {
width: 100%; height: 100%;
margin: 0; padding: 0; border: 0;
background: transparent; color: transparent; caret-color: white;
outline: none; font: inherit;
}
.numora-overlay-host input::selection { background: rgba(255, 255, 255, 0.25); }
.numora-overlay-host input::placeholder { color: transparent; }
`],
})
export class NumoraOverlayComponent implements OnInit, OnDestroy {
@ViewChild('display', { static: true }) display!: ElementRef<HTMLSpanElement>;
@ViewChild('host', { static: true }) host!: ElementRef<HTMLDivElement>;
private numora!: NumoraInput;
private input!: HTMLInputElement;
private syncMorph!: () => void;
private scheduleSync!: () => void;
ngOnInit(): void {
// SSR placeholder is a text node; Torph only animates element children.
this.display.nativeElement.textContent = '';
this.numora = new NumoraInput(this.host.nativeElement, {
formatOn: FormatOn.Change,
thousandStyle: ThousandStyle.Thousand,
decimalMaxLength: 2,
thousandSeparator: ',',
});
this.input = this.numora.getElement();
const morph = new TextMorph({
element: this.display.nativeElement,
ease: { stiffness: 400, damping: 30 },
});
morph.update('0');
this.syncMorph = () => morph.update(this.numora.value || '0');
// Numora writes the formatted value in beforeinput; sync after that handler runs.
this.scheduleSync = () => queueMicrotask(this.syncMorph);
this.input.addEventListener('beforeinput', this.scheduleSync);
this.input.addEventListener('input', this.syncMorph);
}
ngOnDestroy(): void {
this.input?.removeEventListener('beforeinput', this.scheduleSync);
this.input?.removeEventListener('input', this.syncMorph);
}
}Note encapsulation: ViewEncapsulation.None - Numora creates the <input> at runtime, so Angular's default shadow-DOM-style attribute scoping can't reach it. The beforeinput microtask + input listener combination keeps Torph in sync across typing, paste, undo and redo. Torph respects prefers-reduced-motion automatically.
FAQ
Is there a numora-angular package?
No. Numora is intentionally a thin layer over the native <input>. A short standalone directive is the entire Angular adapter.
Does Numora integrate with Angular Reactive Forms?
Yes – the NumoraDirective above implements ControlValueAccessor out of the box, so the same <input numora> works with [(ngModel)] and [formControl] with no extra wiring. Numora's raw value is a string, which matches what FormControl stores – no number-coercion gymnastics, no IEEE 754 rounding errors on currency amounts.
How do I add thousand separators to an Angular number input?
Pass thousandStyle: ThousandStyle.Thousand through the directive's @Input(). Numora formats as the user types, keeps the caret stable through the inserted comma, and emits the raw separator-free string from onChange - feed it into a signal or FormControl<string>.
Does it work with Angular Universal SSR?
Yes. NumoraInput touches the DOM, so initialise it in ngOnInit on the browser platform (guard with isPlatformBrowser if you target the server). Universal renders an empty host element on the server, Numora mounts on hydration.
Why not a native <input type="number">?
Native number inputs lose precision (IEEE 754), have inconsistent mobile keyboards, do not format thousand separators, and break paste sanitisation. Numora keeps the value as a string throughout and handles all of the above.
Numora in other frameworks
Numora's core is framework-agnostic. The same vanilla NumoraInput class powers the numeric input across every modern UI framework:
- Svelte numeric input –
use:numoraaction for Svelte and SvelteKit - Vue numeric input – Vue 3
v-numoracustom directive - SolidJS numeric input – signal-bound
onMountwrapper - React numeric input – drop-in
<NumoraInput />component (numora-react)