Transparência: sou fundador da CPFHub, citada no final. O componente funciona sem nenhuma API.

O campo de CPF parece trivial até você receber os primeiros tickets: "não consigo colar meu CPF", "o cursor pula para o final", "o teclado do celular abre com letras". Vamos construir um componente React que resolve tudo isso sem biblioteca de máscara.

1. Guarde dígitos, mostre máscara

O estado deve conter apenas dígitos. A máscara é derivada na renderização. Isso elimina a maior parte dos bugs.

const soDigitos = (s) => s.replace(/\D/g, "").slice(0, 11); function mascararCpf(d) { return d .replace(/^(\d{3})(\d)/, "$1.$2") .replace(/^(\d{3})\.(\d{3})(\d)/, "$1.$2.$3") .replace(/\.(\d{3})(\d)/, ".$1-$2"); } 

Teste rápido: mascararCpf("52998224725") → "529.982.247-25", e mascararCpf("5299") → "529.9". A máscara progressiva funciona enquanto o usuário digita.

2. Validação dos dígitos

export function cpfValido(d) { if (!/^\d{11}$/.test(d) || /^(\d)\1{10}$/.test(d)) return false; for (let t = 9; t < 11; t++) { let soma = 0; for (let i = 0; i < t; i++) soma += Number(d[i]) * (t + 1 - i); if (Number(d[t]) !== ((soma * 10) % 11) % 10) return false; } return true; } 

3. O componente

import { useId, useState } from "react"; export function CampoCpf({ onValido }) { const id = useId(); const [digitos, setDigitos] = useState(""); const [tocado, setTocado] = useState(false); const completo = digitos.length === 11; const valido = completo && cpfValido(digitos); const erro = tocado && digitos.length > 0 && (!completo || !valido); return ( <div> <label htmlFor={id}>CPF</label> <input id={id} name="cpf" inputMode="numeric" autoComplete="off" placeholder="000.000.000-00" value={mascararCpf(digitos)} onChange={(e) => { const d = soDigitos(e.target.value); setDigitos(d); if (d.length === 11 && cpfValido(d)) onValido?.(d); }} onBlur={() => setTocado(true)} aria-invalid={erro} aria-describedby={erro ? `${id}-erro` : undefined} /> {erro && ( <p id={`${id}-erro`} role="alert"> {completo ? "CPF inválido. Confira os números." : "O CPF tem 11 dígitos."} </p> )} </div> ); } 

Decisões que importam:

  • inputMode="numeric" abre o teclado numérico no celular, mas, ao contrário de type="number", mantém o campo como texto (não perde zero à esquerda nem aceita e).
  • Erro só depois do blur. Mostrar "CPF inválido" enquanto a pessoa ainda digita o terceiro número é irritante.
  • Colar funciona porque normalizamos qualquer string com soDigitos, inclusive 529 982 247 25.
  • aria-invalid e aria-describedby fazem o leitor de tela anunciar o erro.

4. E o cursor?

Como a máscara insere caracteres, editar no meio pode empurrar o cursor. Para a maioria dos formulários isso é aceitável, porque as pessoas digitam CPF do início ao fim. Se o seu caso exige edição no meio, calcule quantos dígitos estavam antes do cursor e reposicione em um useLayoutEffect. Ou use uma biblioteca madura como react-imask.

5. React Hook Form + Zod

Em formulários maiores, centralize a regra no schema:

import { z } from "zod"; const schema = z.object({ nome: z.string().min(3), cpf: z .string() .transform((s) => s.replace(/\D/g, "")) .refine(cpfValido, "CPF inválido"), }); 

O transform garante que o backend recebe só dígitos.

6. Nunca confie só no front

Tudo isso é UX. O backend tem que validar de novo, porque qualquer um pode chamar sua API sem passar pelo formulário. E consultar uma API de CPF direto do navegador expõe sua chave: faça a consulta a partir de um endpoint seu (route handler, function, backend).

7. Pré-preenchendo o nome

Um uso comum: quando o CPF fica válido, o front chama o seu backend, que consulta uma API de CPF e devolve o primeiro nome para confirmação ("Você é Maria?"). Isso reduz digitação e erro. Mostre só o necessário e nunca a data de nascimento completa antes de a pessoa confirmar a identidade.

Sobre a CPFHub

Para esse backend, a CPFHub tem GET /cpf/{cpf} (header x-api-key), que retorna nome, gênero e data de nascimento em ~150 ms típicos (não SLA), com 50 créditos grátis por mês. Detalhes na documentação.