Validação de formulário sem JavaScript: required e pattern
required, pattern, min, max e minlength: o que o navegador valida sozinho, a mensagem que ele mostra e como trocar esse texto padrão.
O navegador já sabe validar formulário. required, type="email", pattern,
min, max e step são atributos que ele lê sozinho na hora do envio: se
alguma regra não bate, o envio não acontece e um balão aparece no primeiro campo
com problema — sem uma linha de JavaScript.
Todos os exemplos aqui são de um formulário de matrícula de uma escola: nome do responsável, e-mail, CEP e idade do aluno. E todos os resultados colados abaixo saíram de execuções reais em três motores de navegador — Chrome 151, Firefox 153 e WebKit 26.5 — porque a parte mais surpreendente desta lição é justamente onde os três discordam.
O navegador já tem uma primeira conferência
Pense na ficha de matrícula entregue numa recepção. Antes de mandar o papel para a secretaria, a atendente confere se nome foi preenchido, se a idade cabe na faixa e se o e-mail tem formato reconhecível. Os atributos de validação do HTML fazem essa primeira conferência no próprio navegador.
O nome técnico é validação de restrições (constraint validation).
required, min, max, pattern e o type alimentam um conjunto de regras;
eles não garantem que o dado seja verdadeiro nem protegem o servidor. Antes do
primeiro envio, escolha um campo e quebre uma regra de propósito. Observe três
coisas: o evento de envio acontece ou não, qual campo recebe foco e o que
validity informa. Esse experimento mostra o mecanismo melhor que decorar uma
lista de atributos.
As regras moram no HTML, não no JavaScript
Este é o formulário inteiro. Repare que não existe <script> nenhum:
<form action="/matricular" method="post">
<label for="responsavel">Nome do responsável</label>
<input id="responsavel" name="responsavel" type="text" required minlength="6">
<label for="email">E-mail</label>
<input id="email" name="email" type="email" required>
<label for="cep">CEP</label>
<input id="cep" name="cep" type="text" required pattern="\d{5}-\d{3}">
<label for="idade">Idade do aluno</label>
<input id="idade" name="idade" type="number" required min="4" max="17" step="1">
<button type="submit">Matricular</button>
</form>Cada atributo é uma restrição declarada. O navegador junta todas e produz, para
cada campo, um veredito e um motivo. A estrutura do <form> e dos <label> é a
mesma de formulário em HTML; o type de cada
campo é o assunto de
tipos de input HTML. O que muda aqui é só
isto: as regras.
O envio que simplesmente não acontece
Para ver o bloqueio de verdade, subi um servidor Express que imprime o que
recebe, e servi o mesmo formulário em duas rotas — uma normal e outra com
novalidate:
import express from 'express';
const app = express();
app.use(express.urlencoded({ extended: false }));
app.post('/matricular', (req, res) => {
console.log(' servidor recebeu:', JSON.stringify(req.body));
res.send('matricula registrada');
});Aí um navegador de verdade abre a página, digita Ana no responsável e
ana@ no e-mail — os dois errados de propósito — e clica em Matricular:
// page é o Playwright: ele abre um Chrome de verdade e digita como uma pessoa
await page.locator('[name=responsavel]').pressSequentially('Ana');
await page.locator('[name=email]').pressSequentially('ana@');
await page.locator('button').click();
console.log(`URL depois do clique: ${new URL(page.url()).pathname}`);A URL não mudou e o servidor não imprimiu nada: a requisição nunca saiu. Esse é
o comportamento padrão de qualquer <form> com restrição não satisfeita.
type="email" aceita muito mais do que você imagina
Aqui mora a maior surpresa da validação nativa. type="email" não verifica se o
e-mail existe, nem se o domínio é real, nem se tem ponto depois do arroba. Ele
aplica um padrão fixo, definido na especificação do HTML, e esse padrão é
deliberadamente frouxo.
Testei doze strings no mesmo <input type="email">, nos três motores. O teste
inteiro é isto, e você pode colar no console de qualquer página que tenha um
campo de e-mail:
const el = document.getElementById('email');
const testar = (v) => { el.value = v; return el.checkValidity(); };
testar('a@b'); // true
testar('ana@gmail'); // true
testar('ana@'); // falseLeia a terceira linha de novo: a@b passa. E ana@gmail, sem .com,
também. Os três motores concordam item por item — o que eles validam é o mesmo,
porque vem da mesma especificação.
A regra, em português: antes do arroba, letras, números e um punhado de símbolos
— mas nada de espaço, vírgula ou parêntese, e é por isso que ana gomes@gmail.com
cai; um arroba só; e depois dele um domínio cujos pedaços comecem e terminem em
letra ou número, e é por isso que ana@-gmail.com cai. Não existe exigência de
TLD porque ana@intranet é um endereço legítimo dentro de uma rede corporativa,
e o HTML não tem como saber se você está numa.
pattern: a âncora invisível e o title que só dois motores leem
pattern recebe uma expressão regular e testa o valor contra ela. A sintaxe é a
mesma de regex em JavaScript, com duas
diferenças que derrubam quem está começando.
A primeira é a ancoragem automática. Você não escreve ^ nem $: o
navegador exige que o padrão case com o valor inteiro.
const cep = document.getElementById('cep'); // pattern="\d{5}-\d{3}"
const testar = (v) => { cep.value = v; return cep.checkValidity(); };
['', '01310-100', 'CEP 01310-100', '01310-100 SP', '01310100'].map(testar);Repare de passagem na primeira linha da saída: campo vazio passa. pattern
só diz “se tiver alguma coisa aqui, tem que ser assim”. Quem obriga o
preenchimento é o required, e por isso o CEP do nosso formulário tem os dois.
A segunda diferença é que não existe onde declarar as flags. Numa regex de
JavaScript você escreve /[a-z]{3}/i para ignorar maiúscula e minúscula; dentro
do atributo cabe só o padrão, sem as barras e sem a letra depois delas. Quem
tenta contornar isso com (?i), que funciona em outras linguagens, cai numa
armadilha silenciosa:
<input id="sigla" type="text" pattern="[a-z]{3}">
<input id="siglaI" type="text" pattern="(?i)[a-z]{3}">Olhe a última linha: 123 também passou. (?i) não é sintaxe válida de regex em
JavaScript, então o padrão inteiro não compila — e um pattern que não compila é
descartado, sem um pio no console. O campo para de validar qualquer coisa, e
o mesmo acontece com qualquer erro de digitação dentro do atributo. Os três
motores devolvem essas mesmas quatro linhas. Quando o padrão precisa aceitar as
duas caixas, escreva as duas no próprio padrão: pattern="[A-Za-z]{3}".
E tem o title. Com o valor 01310 nos dois campos abaixo — um com title e
outro sem — as mensagens saem assim:
<input id="cep" type="text" required pattern="\d{5}-\d{3}">
<input id="cepTitle" type="text" pattern="\d{5}-\d{3}" title="Use o formato 01310-100">=== Firefox 153 === cep value=“01310” [patternMismatch] -> Please match the requested format. cepTitle value=“01310” [patternMismatch] -> Please match the requested format: Use o formato 01310-100.
=== WebKit 26.5 === cep value=“01310” [patternMismatch] -> Match the requested format cepTitle value=“01310” [patternMismatch] -> Match the requested format: Use o formato 01310-100
Firefox e WebKit anexam o title à mensagem; o Chrome ignora. Ou seja: escrever
o title é um bom hábito, mas contar com ele para explicar o formato deixa
metade dos visitantes sem explicação nenhuma. Se a dica do formato precisa
aparecer, ela vai num texto ao lado do campo, ligado por aria-describedby —
assunto de acessibilidade em HTML.
min, max, step — e os atributos que o navegador ignora em silêncio
Em campo numérico e de data, min e max viram limites de verdade:
<input id="idade" type="number" min="4" max="17" step="1">
<input id="matricula" type="date" min="2026-02-01" max="2026-02-28">Digitando 9.5 na idade e 2026-03-15 na data de matrícula:
Repare que o step não é enfeite: com step="1", 9.5 é inválido, e o
navegador ainda sugere os dois valores válidos mais próximos.
Agora a parte que custa tempo. Alguns atributos de validação não valem em alguns tipos de campo — e o navegador não avisa, não loga, não faz nada:
<input id="turma" type="number" minlength="6" maxlength="3">
<input id="cepNumero" type="number" pattern="\d{5}-\d{3}">
<input id="cepCurto" type="text" maxlength="3">Digitando 9 na turma, 01310 no CEP numérico e 01310-100 no CEP de texto:
minlength, maxlength e pattern só existem para campos de texto
(text, search, url, tel, email, password). Em type="number" eles
são decoração: o campo turma tem um caractere com minlength="6" e mesmo
assim é considerado válido.
E olhe o terceiro campo: maxlength="3" num campo de texto não invalida — ele
corta. 01310-100 virou 013 enquanto a pessoa digitava. maxlength é
limite físico do campo; minlength é regra de validação. São coisas diferentes
com nomes parecidos.
| atributo | vale em | o que faz quando a regra quebra |
|---|---|---|
required |
quase todos | marca valueMissing e barra o envio |
type="email" / url |
os próprios tipos | marca typeMismatch |
pattern |
só campos de texto | marca patternMismatch |
minlength |
só campos de texto | marca tooShort, e só depois de digitação |
maxlength |
só campos de texto | não marca nada na digitação: corta o valor no limite |
min / max |
number, range, date, time | marca rangeUnderflow / rangeOverflow |
step |
number, range, date, time | marca stepMismatch |
minlength só existe depois que a pessoa digita
Esta é a pegadinha mais cara da lista, e ela não aparece em nenhuma tabela de
atributos. minlength não é avaliado enquanto o valor do campo não tiver
sido alterado por quem está usando a página. Dois campos idênticos, o mesmo
valor Ana, e resultados opostos:
// os dois campos são <input type="text" minlength="6">
// campo 1: o valor entra por código
document.getElementById('responsavelPreenchido').value = 'Ana';
// campo 2: as mesmas três letras, tecla por tecla
// (de novo o Playwright digitando no lugar da pessoa)
await page.locator('#responsavelDigitado').pressSequentially('Ana');O campo preenchido por código passou na validação com três caracteres. Isso
significa que preencher um formulário a partir de dados salvos — um rascunho de
matrícula, por exemplo — desliga o minlength daqueles campos sem avisar
ninguém. Se essa regra importa para você, ela precisa existir também fora do
HTML.
:invalid acusa cedo demais; :user-invalid acusa na hora certa
O CSS enxerga o estado de validação de cada campo. O seletor antigo é
:invalid, e ele tem um defeito grave: um campo required vazio já é inválido
no instante em que a página carrega. Resultado, o formulário abre todo
pintado de vermelho antes de a pessoa digitar qualquer coisa.
:user-invalid conserta exatamente isso. Contei quantos campos cada seletor
pega, em três momentos:
input:invalid { border-color: crimson; } /* pega cedo demais */
input:user-invalid { border-color: crimson; } /* espera a interação */Os dois campos estão inválidos o tempo todo para o :invalid. Para o
:user-invalid, nenhum é — até a pessoa mexer no campo e sair dele. É o
comportamento que todo mundo escrevia à mão em JavaScript, e que hoje vem de
graça no CSS. Se as pseudo-classes ainda são novidade para você,
especificidade e estados no CSS
explica a família inteira.
Quando o HTML não dá conta: setCustomValidity
CPF, “a turma tem vaga”, “esse e-mail já está matriculado” — nenhuma dessas regras cabe num atributo. Para elas, você calcula o resultado e entrega ao navegador, que passa a tratar a sua mensagem como se fosse dele:
const cep = document.getElementById('cep'); // valor digitado: 01310
cep.setCustomValidity('Digite o CEP no formato 01310-100.');Duas coisas para guardar. A mensagem customizada substitui a do navegador enquanto existir, e a única forma de apagá-la é passar string vazia. E ela invalida o campo por conta própria, mesmo que todos os atributos estejam satisfeitos:
const idade = document.getElementById('idade'); // type="number" min="4" max="17" value="10"
idade.setCustomValidity('Turma cheia para esta idade.');O campo tinha 10, dentro de min="4" e max="17", e mesmo assim o formulário
inteiro ficou inválido. É o gancho entre o HTML e o seu código — e o lado
JavaScript dessa API está inteiro em
validar formulário com JavaScript.
novalidate desliga o balão — e o curl nunca viu balão nenhum
novalidate no <form> desliga duas coisas: o balão nativo e o bloqueio
automático do envio. É o que você usa quando vai desenhar as próprias mensagens.
A mesma página de antes, agora com o atributo:
<form action="/matricular" method="post" novalidate>Com os mesmos valores errados — Ana e ana@ — o clique agora envia. A linha do
servidor aparece primeiro porque o POST chega antes de o roteiro conseguir
imprimir a URL:
E aqui está o ponto que fecha a lição. Um atributo mudou, e o dado inválido
chegou inteiro no servidor. Nem precisa de novalidate: qualquer cliente HTTP
fala direto com a rota, sem nunca ter carregado o seu HTML.
curl -s -i -X POST http://localhost:3311/matricular \
-d "responsavel=Ana&email=ana@&cep=xx&idade=99"matricula registrada — log do servidor — servidor recebeu: {“responsavel”:“Ana”,“email”:“ana@”,“cep”:“xx”,“idade”:“99”}
Idade 99 numa escola que aceita de 4 a 17 anos, CEP xx, e-mail sem domínio —
tudo aceito, porque required, pattern e max são regras que moram no
navegador de quem acessa. A validação nativa é interface: ela avisa a pessoa
antes de ela perder a viagem. Quem protege o dado é o servidor, com as mesmas
regras escritas de novo.
O que vem depois
Escreva as regras nos atributos primeiro, :user-invalid no CSS depois, e só
então JavaScript — nessa ordem, cada camada que falhar deixa a anterior de pé. A
próxima lição da trilha de HTML troca os campos pelas tags
que dão significado à página, e o
guia completo de HTML mostra onde cada assunto entra na
sequência.
Prefere aprender em vídeo?
Tem uma aula sobre este assunto no nosso canal.
Perguntas frequentes
A validação nativa funciona com o JavaScript desligado?
Dá para mudar a cor e a fonte do balão de erro do navegador?
O pattern aceita as mesmas regras de uma regex de JavaScript?
Se o HTML já valida, ainda preciso validar no back-end?
Dúvidas e comentários
Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.
Entrar para perguntarÉ o mesmo login gratuito dos cursos.
Nenhuma dúvida por aqui ainda — a primeira pode ser a sua.
Todo o código deste artigo foi executado em Chrome 151, Firefox 153 e WebKit 26.5 (Playwright) no macOS 27, e as saídas exibidas são as reais — como produzimos este conteúdo.
Fontes consultadas
- MDN — Validação de formulário no lado do cliente — developer.mozilla.org
- HTML Standard — Valid e-mail address — html.spec.whatwg.org
- HTML Standard — Constraint validation — html.spec.whatwg.org
- MDN — :user-invalid — developer.mozilla.org



