Making OTP input in Angular
It’s a common need to have an input which value is stored and displayed differently. At times, it also has extra controls to enter the value (e.g. date pickers). How do you make one?
In essence, creating a custom control comes down to implementing ControlValueAccessor
interface and providing the component as NG_VALUE_ACCESSOR
. After that, you can do literally anything you want. You could build a canvas-based control for example. What matters is that you call a function passed as an argument in the registerOnChange
method to tell Angular that the control has its value updated, and that you handle a writeValue
method so that if a value is set from the outside of the control (e.g. control.setValue()
) your control will reflect that in the UI.
Indeed, you could say that ControlValueAccessor
is too boilerplate-ish and that it’s way easier to use regular Input
s and Output
s in order to handle the data. But, this is “easy” only for you as a developer of the component. For the consumer it draws it impossible to use Angular’s way of handling forms (ngModel
bindings and formControl
s) and instead forces them to imperatively handle data changes. This in its turn makes it way more cluttered than a “standard” approach. And we have not yet talked about validation. Imagine how it would have been handled in a “simple” (the one that doesn’t require ControlValueAccessor
).
<!-- Hard to manage control -->
<custom-control
(change)="value = $event"
(validChange)="valid = $event"
[value]="value" />
<!-- VS -->
<!-- Easy to use and manage control. All validations are integrated with reactive forms by default -->
<custom-control [formControl]="control" />
Scaffolding a new component
For starters, we want to create a new component. ng g c otp-input
would do the job of scaffolding all the files required for us. Here, we are using shorthands: “g” for generate, “c” for component.
When the component is created we tell the TypeScript that it implements ControlValueAccessor
, and right away let’s create four empty methods: writeValue
, registerOnChange
, registerOnTouched
, setDisabledState
. These are the methods required by the interface we are implementing. Let’s talk about them.
writeValue
will be called whenever we update the value outside of the component (by usingcontrol.setValue()
etc.). Here, we will store the value passed from the outside and update the UI. Also, the method is called when the component first rendered and its control has a value set to it.registerOnChange
here we will get a function passed as an argument which we will need to store for later use. When the stored function gets called Angular will set the value passed to the function as the current value of the control.registerOnTouched
here we get a function passed as an argument which we will need to store for lager use. This function as the name states tells Angular that control became “dirty”. For regular inputs Angular calls the function on blur event.setDisabledState
this one gets called whenever the disabled state is changed by the consumer. It accepts aboolean
argument which tells us whether the control was set to disabled or enabled state.
Let’s store the passed functions.
// otp-input.component.ts
class OtpInputComponent implements ControlValueAccessor {
// these are two props we will be storing functions in
onChange?: (value: string) => void;
onTouched?: () => void;
// here, we simply save the functions into the props
registerOnChange(fn: any): void {
this.onChange = fn;
}
registerOnTouched(fn: any): void {
this.onTouched = fn;
}
writeValue(value: string): void {
}
setDisabledState(isDisabled: boolean): void {
}
}
Also, we want Angular to register the component as a control provider. For this we will define providers: []
in the component’s metadata. There, we will add following provider config
{
provide: NG_VALUE_ACCESSOR,
useExisting: OtpInputComponent,
multi: true,
}
Now, we can already use component as a regular input (use ngModel
, formControl
on it).
Spawning the inputs
It’s customary for OTP inputs to have 4–6 digits in it, but instead of hard coding all the fields we could enable the consumer to define how many digits they would want to have. This is especially handy if you want to share your component in a library.
First off, I would start with writing a simple helper function which will generate a FormArray
for us (we will use ReactiveForms as we still need to work with native inputs and we don’t want to re-invent the wheel).
function getFormArray(size: number): FormArray {
const arr = [];
for (let i = 0; i < size; i++) {
arr.push(new FormControl(''));
}
return new FormArray(arr);
}
After the function is created, we create an Input
setter to accept the size (amount of digits) of the field, we will store the passed value as we are going to need it later, we create an initial FormArray
of inputs with some default size.
@Input()
set size(size: number) {
this.inputs = getFormArray(size);
this.#size = size;
}
#size = 4;
inputs = getFormArray(this.#size);
As you may notice, we are using #size
which is a JS feature letting us create a truly private field. It’s being polyfilled for the older browsers by using WeakMap
so it’s safe to use. By making the field private we don’t need to come up with a synonym or to use an underscore to avoid naming collision.
Let’s add those inputs to the markup!
<input
*ngFor="let input of inputs.controls"
[formControl]="$any(input)"
type="text"
inputmode="numeric"
/>
We are accessing the property controls
on FormArray
which contains all our controls. Then, we are using $any(input)
to pass the FormControl
to the [formControl]
input. $any
makes a typecast to any
in TypeScript which is a bad thing to use usually, but here we will neglect it as we know for sure that it’s a FormControl
and not some mere AbstractFormControl
.
We set type="text"
and inputmode="numeric"
which might leave you wondering why not just type="number"
. But, if you remember how type="number"
works you will recall that it: adds increment/decrement buttons, can have negative values. Instead, we want to use regular text input and have inputmode="numeric"
on it so that touch devices will show numeric keyboard.
Handle the input
If you start typing you will enter everything into one single field with no limits whatsoever. This is not what a user would expect, would they. The idea is simple — we want to sift the focus to a next input after each key stroke. If an input receives a focus we want it to select the entered value so that its easy to change a digit. Also, on each keystroke we still want to clear an input of any value it had before — this handles the case when user tries to enter another digit by removing the selection.
@ViewChildren('inputEl') inputEls!: QueryList<ElementRef<HTMLInputElement>>;
#scheduledFocus: number = null;
handleInput() {
this.#updateWiredValue();
if (this.#scheduledFocus != null) {
this.#focusInput(this.#scheduledFocus);
this.#scheduledFocus = null;
}
}
handleKeyPress(e: KeyboardEvent, idx: number) {
const isDigit = /\d/.test(e.key);
// Safari fires Cmd + V through keyPress event as well
// so we need to handle it here and let it through
if (e.key === 'v' && e.metaKey) {
return true;
}
if (isDigit && idx + 1 < this.#size) {
// If user inputs digits & we are not on the last input we want
// to advance the focus
this.#scheduledFocus = idx + 1;
}
if (isDigit && this.inputs.controls[idx].value) {
// If user deselects an input which already has a value
// we want to clear it so that it doesn't have more than 1 digit
this.inputs.controls[idx].setValue('');
}
return isDigit;
}
handleFocus(e: FocusEvent) {
// Select previously entered value to replace with a new input
(e.target as HTMLInputElement).select();
}
#focusInput(idx: number) {
// In order not to interfere with the input we setTimeout
// before advancing the focus
setTimeout(() => this.inputEls.get(idx)?.nativeElement.focus());
}
#updateWiredValue() {
// We want to expose the value as a plain string
//
// In order not to interfere with the input we setTimeout
// before advancing the focus
setTimeout(() => this.onChange?.(this.inputs.value.join('')));
}
Adding setTimeout
to shift the focus in the inputs and to call onChange
is a necessary evil as otherwise they get called before the input registers the key press. Also, you could notice that I’m writing this.onChange?.()
. This is done because the consumer could use your control without first importing FormsModule
or ReactiveFormsModule
which would lead to the functions absent and hence to an error when they get called.
Notice the this.#updateWiredValue();
which gets called when the user types in a digit. This is the key part here as it notifies Angular of value change and thus a control bound to our component has its value updated. If we didn’t call the this.onChange
in it our component would be isolated from the app and no value would be present in the attached control.
You might be wondering why we are using
#scheduledFocus
property to set an index and then handleinput
event for focus change & value updates. Well, this is a workaround for mobile Safari where it fires events in a different order than desktop browsers do. This is what cross-browser looks like.
After adding the methods, we register them as callbacks for the events.
Now, that you are typing the focus is nicely shifting to the next field. It may seem like a done deal, but have you tried deleting the values? The focus doesn’t go back!
handleKeyDown(e: KeyboardEvent, idx: number) {
if (e.key === 'Backspace' || e.key === 'Delete') {
if (idx > 0) {
this.#scheduledFocus = idx - 1;
}
}
}
After writing a simple handler that would move the focus backwards we get a following HTML markup.
<input
#inputEl
*ngFor="let input of inputs.controls; let i = index"
(focus)="handleFocus($event)"
(blur)="onTouched?.()"
(keypress)="handleKeyPress($event, i)"
(input)="handleInput()"
(keydown)="handleKeyDown($event, i)"
[formControl]="$any(input)"
type="text"
inputmode="numeric"
/>
It also now has a (blur)
event handler and what it does is just marks the field (our whole component-control) as dirty. That mimics the regular input
s behavior in Angular.
Helping the mobile users
To help mobile users even more we will specify the autocomplete
attribute, but only on the first field so not to confuse the devices.
<input
*ngFor="let input of inputs.controls; let i = index"
[formControl]="$any(input)"
[attr.autocomplete]="i === 0 ? 'one-time-code' : null"
type="text"
inputmode="numeric"
/>
This autocomplete
not only suggests for mobile users to fill in with SMS codes, but also enables Authenticators to fill in with one time codes generated.
This especially shines in Apple ecosystem where you can use native Passwords as an Authenticator to generate one-time codes and it will suggest to fill with the code even on a laptop the same way you would auto-fill a password!
Users will say “thank you” for not making them go manual to fill in the code.
But, now we have a problem. If a user enters a code by suggested autocomplete — it will simply paste the value and we will have all the digits in one field. Let’s fix it!
Handle the paste
First, we register a handler for the paste
event on the inputs by adding (paste)="handlePaste($event, i)"
. Now we define the method in the component.
handlePaste(e: ClipboardEvent, idx: number) {
e.preventDefault();
if (idx !== 0) {
// If the target input is not the first one - ignore
return;
}
const pasteData = e.clipboardData?.getData('text');
// \d stands for digit in RegExp
// \\d escapes the slash before the "d" which we need
// because of the regexp being written as a string
const regex = new RegExp(`\\d{${this.#size}}`);
if (!pasteData || !regex.test(pasteData)) {
// If there is nothing to be pasted or the pasted data does not
// comply with the required format - ignore the event
return;
}
for (let i = 0; i < pasteData.length; i++) {
this.inputs.controls[i].setValue(pasteData[i]);
}
this.#focusInput(this.inputEls.length - 1);
this.#updateWiredValue();
this.onTouched();
}
In the snippet above, we do a few checks against pasted value to ensure it’s valid and can be handled. Then we spread it across the form controls. After that, we shift the focus to the last input and call the saved function in onChange
so that Angular writes the value to the associated form control. We also call this.onTouched()
to count for the case when the control is prefilled without first receiving focus.
Now, we can paste four digits and they perfectly fit each into their own input field. Splendid!
Filling in the gaps
We are still having two empty methods. Let’s add some code to them.
writeValue(value: string): void {
if (isDevMode() && value?.length) {
throw new Error('Otp input is not supposed to be prefilled with data');
}
// Reset all input values
this.inputs.setValue(new Array(this.#size).fill(''));
}
setDisabledState(isDisabled: boolean): void {
if (isDisabled) {
this.inputs.disable();
} else {
this.inputs.enable();
}
}
isDevMode()
is a handy function which will tree shake the condition for production build so we can use it to give insights to the consumers.
After that, we have a fully implemented ControlValueAccessor
interface which can work with all possible scenarios!
Is there anything left? Sure, we still have validation yet to implement. It’s not a part of the ControlValueAccessor
, however it’s natural that we want to mark our form control as invalid when it doesn’t have all the digits filled in.
Adding validation
Angular also has an easy way to add a validator (akin to those shipped with Angular) by adding yet another provider configuration. Luckily, we don’t need to create another component or directive. We can simply add it right here as it doesn’t make sense to have the control and its only validation apart.
First, we add a new provider to the component.
{
provide: NG_VALIDATORS,
useExisting: OtpInputComponent,
multi: true,
},
Now, add a Validator
to the implemented interfaces of our class. It will require us to add another method called validate
which has the same signature as any other validation function that you may have already written. Let’s implement it.
validate(control: AbstractControl<string, string>): ValidationErrors | null {
if (!control.value || control.value.length < this.#size) {
return {
otpInput: 'Value is incorrect',
};
}
return null;
}
As with any control with a validator that works natively (to Angular) with forms our control receives class list updates when the validity changes.
At last, we have come to an end of our journey. Let’s sum up what we have learnt:
- Building custom form controls isn’t scary.
- We can greatly improve UX by adding simple things such as
autocomplete="one-time-code"
,type="text" inputmode="numeric"
. - We already can use native private fields and methods in JS to our advantage.
- By implementing abstractions such as
ControlValueAccessor
,Validator
we enable ourselves to integrate with Angular ecosystem seamlessly (my applauds to the great minds who foresaw and designed abstractions for us to extend and override).
The final code can be found below.