Pular para o conteúdo
Cursos

DevClub

LógicaFront-endBack-endMobile

IA Club

IA na prática
Estudar programaçãoEstudar IA
LiçãoIntermediáriocódigo testado

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.

Rodolfo Mori12 min de leitura

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:

html
<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:

js
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:

js
// 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}`);
/normal -> URL depois do clique: /normal corpo da página: " Matricular"

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:

js
const el = document.getElementById('email');
const testar = (v) => { el.value = v; return el.checkValidity(); };

testar('a@b');        // true
testar('ana@gmail');  // true
testar('ana@');       // false
valor | Chrome 151 | Firefox 153 | WebKit 26.5 "ana@gmail.com" | passa | passa | passa "ana@gmail" | passa | passa | passa "a@b" | passa | passa | passa "ana@localhost" | passa | passa | passa "ana.souza+matricula@escola.com.br"| passa | passa | passa "ANA@ESCOLA.COM.BR" | passa | passa | passa "ana@gmail..com" | BARRA | BARRA | BARRA "ana@@gmail.com" | BARRA | BARRA | BARRA "ana gomes@gmail.com" | BARRA | BARRA | BARRA "@gmail.com" | BARRA | BARRA | BARRA "ana@" | BARRA | BARRA | BARRA "ana@-gmail.com" | BARRA | BARRA | BARRA

Leia 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.

js
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);
pattern com campo vazio -> true pattern com "01310-100" -> true pattern com "CEP 01310-100" -> false pattern com "01310-100 SP" -> false pattern com "01310100" -> false

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:

html
<input id="sigla"  type="text" pattern="[a-z]{3}">
<input id="siglaI" type="text" pattern="(?i)[a-z]{3}">
sigla pattern="[a-z]{3}" value="ANA" -> false sigla pattern="[a-z]{3}" value="ana" -> true siglaI pattern="(?i)[a-z]{3}" value="ANA" -> true siglaI pattern="(?i)[a-z]{3}" value="123" -> true

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:

html
<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">
=== Chrome 151 (headless) === cep value="01310" [patternMismatch] -> Please match the requested format. cepTitle value="01310" [patternMismatch] -> Please match the requested format.

=== 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:

html
<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:

idade type=number value="9.5" valido=false [stepMismatch] Please enter a valid value. The two nearest valid values are 9 and 10. matricula type=date value="2026-03-15" valido=false [rangeOverflow] Value must be 28/02/2026 or earlier.

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:

html
<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:

turma type=number value="9" valido=true [] cepNumero type=number value="01310" valido=true [] cepCurto type=text value="013" valido=true []

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:

js
// 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');
valor posto por JavaScript -> tooShort=false checkValidity=true msg="" valor digitado pela pessoa -> tooShort=true checkValidity=false msg="Please lengthen this text to 6 characters or more (you are currently using 3 characters)."

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:

css
input:invalid      { border-color: crimson; }  /* pega cedo demais */
input:user-invalid { border-color: crimson; }  /* espera a interação */
ao carregar a página -> {"invalid":2,"userInvalid":0} depois de digitar "Ana" -> {"invalid":2,"userInvalid":0} depois de sair do campo -> {"invalid":2,"userInvalid":1}

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:

js
const cep = document.getElementById('cep'); // valor digitado: 01310
cep.setCustomValidity('Digite o CEP no formato 01310-100.');
mensagem padrão -> "Please match the requested format." depois de setCustomValidity-> {"msg":"Digite o CEP no formato 01310-100.","customError":true,"valido":false} depois de limpar com "" -> {"msg":"Please match the requested format.","customError":false,"valido":false}

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:

js
const idade = document.getElementById('idade'); // type="number" min="4" max="17" value="10"
idade.setCustomValidity('Turma cheia para esta idade.');
idade=10, sem custom -> {"campo":true,"form":true} idade=10, com custom -> {"campo":false,"form":false,"estados":["customError"]}

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:

html
<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:

servidor recebeu: {"responsavel":"Ana","email":"ana@"} /sem-validacao -> URL depois do clique: /matricular corpo da página: "matricula registrada"

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.

bash
curl -s -i -X POST http://localhost:3311/matricular \
  -d "responsavel=Ana&email=ana@&cep=xx&idade=99"
HTTP/1.1 200 OK X-Powered-By: Express Content-Type: text/html; charset=utf-8 Content-Length: 20 ETag: W/"14-s6Ob9xbcfUXHUZbqNJRij2Zfcrg" Date: Sat, 22 Aug 2026 20:30:32 GMT Connection: keep-alive Keep-Alive: timeout=5

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.

Ver todos os vídeos do canal
  • html
  • validacao
  • required
  • pattern
  • formulario

Perguntas frequentes

A validação nativa funciona com o JavaScript desligado?
Funciona. Quem lê required, pattern, min e max é o próprio navegador, na hora do envio. É a única camada de validação do seu formulário que continua de pé quando o script quebra, demora ou nem carrega.
Dá para mudar a cor e a fonte do balão de erro do navegador?
Não. O balão é interface do navegador, não elemento do documento, e nenhum seletor de CSS alcança ele. Para ter mensagem com o visual do seu site, você põe novalidate no form e desenha a mensagem você mesmo.
O pattern aceita as mesmas regras de uma regex de JavaScript?
A sintaxe é a mesma, mas com duas diferenças que pegam todo mundo. O navegador ancora o padrão no começo e no fim sozinho, e não existe lugar para declarar flags como i ou g dentro do atributo.
Se o HTML já valida, ainda preciso validar no back-end?
Precisa, e não é opcional. Todo atributo de validação vive no HTML que chega no computador de quem acessa — apagar um required no DevTools leva cinco segundos, e um cliente HTTP nem passa pelo formulário.

Dúvidas e comentários

Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.

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

  1. MDN — Validação de formulário no lado do cliente — developer.mozilla.org
  2. HTML Standard — Valid e-mail address — html.spec.whatwg.org
  3. HTML Standard — Constraint validation — html.spec.whatwg.org
  4. MDN — :user-invalid — developer.mozilla.org

Continue por aqui