Tela de login com OTP (one-time password) em JavaScript

Como funciona por trás dos panos a telinha de código de verificação com seis dígitos, incluindo navegação automática entre campos, backspace e colar o código inteiro de uma vez.


Login com OTP é aquele fluxo que praticamente todo mundo já usou: você digita o e-mail, recebe um código de uso único (OTP significa one-time password) e digita esse código numa tela com vários campos separados, um para cada dígito. A parte de gerar o código, mandar por e-mail ou SMS e validar no servidor fica para outro momento. Aqui o foco é só a telinha: fazer os seis campos de input se comportarem do jeito que a gente já está acostumado a ver nesse tipo de tela.

O problema de partida

O HTML de base é simples: uma div com seis inputs dentro, nada além disso. Sem JavaScript, cada input funciona isolado, então dá para digitar vários números no mesmo campo e nada acontece automaticamente. O que falta implementar:

  • Ao digitar um número, o foco pula para o próximo campo.
  • Ao apertar backspace, o número anterior é apagado e o foco volta.
  • Ao colar um código inteiro copiado do e-mail, ele se distribui automaticamente pelos seis campos.

Guardando o estado num array

A lógica inteira gira em torno de uma ideia simples: o usuário digita, isso atualiza um array na memória, e só depois esse array é usado para atualizar o que aparece na tela. Nunca o contrário.

const otp = [1, 2, 3];

Cada posição do array corresponde a um dígito do código. Para pegar os seis inputs do HTML:

const inputs = document.querySelectorAll(".otp-area input");

Preenchendo os campos a partir do array

A primeira função percorre as seis posições e, para cada uma, verifica se existe um valor correspondente no array. Se existir, preenche o input; se não, deixa vazio:

function updateOtp() {
  for (let i = 0; i < 6; i++) {
    inputs[i].value = otp[i] !== undefined ? otp[i] : "";
  }
 
  if (otp.length < 6) {
    inputs[otp.length].focus();
  }
}
 
updateOtp();

O detalhe do !== undefined importa porque o número zero, sozinho, é considerado falso em uma verificação simples de if. Sem esse cuidado, um código com zero no meio simplesmente não aparece no campo correspondente.

A parte final da função foca automaticamente no próximo campo vazio, então assim que os três primeiros dígitos são preenchidos, o cursor já fica pronto no quarto campo, sem precisar clicar em nada.

Capturando o que o usuário digita

Cada input recebe um listener de keyup, que dispara para qualquer tecla, número, backspace ou qualquer outra:

inputs.forEach((input) => {
  input.addEventListener("keyup", handleInputKey);
});

Dentro da função, o primeiro passo é identificar o backspace:

function handleInputKey(event) {
  const code = event.code.toLowerCase();
 
  if (code === "backspace") {
    otp.pop();
    updateOtp();
    return;
  }
 
  if (code.indexOf("digit") === 0) {
    const key = parseInt(event.key);
    otp.push(key);
    updateOtp();
  }
}

Backspace remove o último item do array com pop, e a interface é atualizada em seguida. Para identificar um número, a verificação usa event.code, que nos navegadores retorna algo como Digit1, Digit2 etc. Testando se essa string começa com digit, o código separa teclas numéricas de qualquer outra tecla (letras, enter, setas). O valor real do dígito vem de event.key, convertido para inteiro com parseInt.

Depois de identificar um número, ele vai direto para o array com push, e a função updateOtp cuida de exibir tudo e mover o foco.

Colando o código inteiro

Copiar e colar o código recebido por e-mail ou SMS é um comportamento que precisa funcionar, mas só faz sentido no primeiro campo. O evento certo para isso é o paste, que existe apenas em inputs e em elementos com contenteditable:

inputs[0].addEventListener("paste", handlePaste);
 
function handlePaste(event) {
  event.preventDefault();
 
  const pasteContent = event.clipboardData.getData("text").trim();
 
  for (let i = 0; i < pasteContent.length; i++) {
    if (i >= 6) break;
 
    const digit = parseInt(pasteContent[i]);
 
    if (digit !== undefined && !Number.isNaN(digit)) {
      otp.push(digit);
    }
  }
 
  updateOtp();
}

event.preventDefault() bloqueia o comportamento padrão do navegador, que jogaria todo o texto colado dentro de um único campo. Em vez disso, o conteúdo colado é lido via clipboardData.getData("text"), com um trim() para remover espaços que às vezes vêm junto (é comum um código copiado de um e-mail trazer espaços extras no começo ou no fim).

A partir daí, o código percorre caractere por caractere, ignora qualquer coisa que não seja número (com parseInt mais uma checagem de NaN) e limita a leitura aos seis primeiros caracteres úteis, já que o array só tem seis posições. No fim, updateOtp distribui tudo pelos campos de uma vez.

Juntando as peças

Com essas três funções, os seis campos passam a se comportar como qualquer tela de OTP que a gente já usou: digitar avança o foco, apagar volta o foco e remove o dígito, e colar o código inteiro preenche tudo automaticamente. A partir daqui, o próximo passo natural é a parte de servidor: gerar o código, definir um tempo de expiração, enviar por e-mail e validar quando o usuário confirma. Mas isso já é um assunto à parte, essa telinha aqui já está pronta para receber esse fluxo por cima.