Capa do Ebook gratuito Arquitetura de CSS Escalável: BEM, ITCSS e Design Tokens para Projetos Reais

Arquitetura de CSS Escalável: BEM, ITCSS e Design Tokens para Projetos Reais

Novo curso

22 páginas

Formulários escaláveis: inputs, validação visual, mensagens de erro e consistência de espaçamento

Capítulo 14

Tempo estimado de leitura: 15 minutos

+ Exercício

O que torna formulários difíceis de escalar

Formulários concentram muitos detalhes visuais e comportamentais: diferentes tipos de input, estados (default, hover, focus, disabled, read-only), validação (sucesso/erro/aviso), mensagens auxiliares, ícones, máscaras, campos compostos (ex.: telefone com DDI), além de variações de densidade e largura. Em projetos reais, o problema não é “estilizar um input”, e sim manter consistência quando dezenas de telas e times começam a criar variações locais para resolver casos pontuais.

Um formulário escalável é aquele em que: (1) o espaçamento entre rótulo, campo e mensagem é previsível; (2) estados visuais são consistentes e fáceis de aplicar; (3) mensagens de erro não “quebram” o layout; (4) componentes compostos (input com ícone, select custom, textarea com contador) seguem o mesmo contrato; (5) a validação visual é clara sem depender de hacks de especificidade.

Neste capítulo, o foco é construir um conjunto de componentes e padrões de composição para formulários: um “Field” (campo) como unidade, inputs reutilizáveis, validação visual, mensagens de erro e regras de espaçamento. A ideia é que qualquer tela consiga montar formulários complexos combinando peças previsíveis, sem criar CSS ad hoc.

Contrato do componente de campo (Field): a unidade que organiza tudo

Em vez de estilizar cada input isoladamente, trate o “campo” como um componente que encapsula: label, controle (input/select/textarea), texto de ajuda, mensagem de erro e elementos opcionais (ícone, prefixo/sufixo, contador). Esse contrato reduz variações e centraliza decisões de espaçamento e estados.

Estrutura HTML recomendada

Um padrão simples e flexível é:

Continue em nosso aplicativo

Você poderá ouvir o audiobook com a tela desligada, ganhar gratuitamente o certificado deste curso e ainda ter acesso a outros 5.000 cursos online gratuitos.

ou continue lendo abaixo...
Download App

Baixar o aplicativo

<div class="c-field" data-state="default">  <label class="c-field__label" for="email">E-mail</label>  <div class="c-field__control">    <input class="c-input" id="email" name="email" type="email" autocomplete="email" />  </div>  <p class="c-field__hint" id="email-hint">Use um endereço válido.</p>  <p class="c-field__message" id="email-error" role="alert"></p></div>

Pontos importantes: (1) o wrapper .c-field controla espaçamentos e estados; (2) .c-field__control permite inserir adornos (ícones/prefixos) sem alterar o input; (3) .c-field__message existe sempre (mesmo vazio) para evitar “pulos” de layout quando o erro aparece; (4) IDs para associar aria-describedby quando necessário.

Estados do Field como fonte de verdade

Em vez de espalhar classes de erro em vários lugares, defina o estado no wrapper e deixe os elementos internos reagirem. Você pode usar modificadores (ex.: c-field--invalid) ou atributos (ex.: data-state="invalid"). O importante é ter um único ponto de controle.

<div class="c-field c-field--invalid">  ...</div>

Isso facilita integração com validação no front-end (React/Vue/Angular) ou validação server-side, porque o estado pode ser aplicado no componente de campo, sem reescrever CSS.

Inputs escaláveis: base única e variações controladas

Inputs, selects e textareas devem compartilhar uma base visual: altura, tipografia, padding, borda, raio, cores e comportamento de foco. A consistência vem de um conjunto mínimo de regras comuns e de variações explícitas (tamanho, densidade, largura, com ícone, etc.).

Base do input (com tokens)

Assumindo que você já possui tokens (cores, espaçamentos, raios), o input pode ser definido assim:

.c-input {  width: 100%;  font: inherit;  color: var(--color-text);  background: var(--color-surface);  border: 1px solid var(--color-border);  border-radius: var(--radius-sm);  padding: var(--space-2) var(--space-3);  min-height: 2.75rem;  outline: none;  transition: border-color 120ms ease, box-shadow 120ms ease, background 120ms ease;}.c-input::placeholder {  color: var(--color-text-muted);}.c-input:focus {  border-color: var(--color-focus);  box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-focus) 25%, transparent);}.c-input:disabled {  background: var(--color-surface-disabled);  color: var(--color-text-disabled);  border-color: var(--color-border-muted);  cursor: not-allowed;}.c-input[readonly] {  background: var(--color-surface-readonly);}

Repare que a base não “sabe” sobre erro/sucesso. Esses estados vêm do .c-field. Isso evita duplicação e garante que qualquer controle dentro do field responda ao mesmo estado.

Variações de tamanho e densidade

Formulários costumam precisar de densidade (compacto vs confortável). Em vez de criar “inputs diferentes”, crie modificadores no Field que ajustem o controle interno. Exemplo:

.c-field--dense .c-input {  padding: var(--space-1) var(--space-2);  min-height: 2.25rem;}.c-field--lg .c-input {  padding: var(--space-3) var(--space-4);  min-height: 3.25rem;}

Assim, o mesmo input atende vários contextos (tabelas, sidebars, páginas de cadastro) sem quebrar o padrão.

Inputs com prefixo/sufixo e ícones (adornos)

Um caso recorrente é colocar ícone de busca, prefixo de moeda ou botão “mostrar senha”. Evite posicionar ícones dentro do input com hacks; use um container de controle que organiza adornos e o input.

<div class="c-field">  <label class="c-field__label" for="price">Preço</label>  <div class="c-field__control c-field__control--adorned">    <span class="c-field__prefix" aria-hidden="true">R$</span>    <input class="c-input c-input--with-prefix" id="price" inputmode="decimal" />    <span class="c-field__suffix" aria-hidden="true">/mês</span>  </div></div>
.c-field__control--adorned {  display: flex;  align-items: center;  gap: var(--space-2);  padding: 0 var(--space-3);  border: 1px solid var(--color-border);  border-radius: var(--radius-sm);  background: var(--color-surface);}.c-field__control--adorned .c-input {  border: 0;  box-shadow: none;  padding: var(--space-2) 0;  min-height: 2.75rem;  background: transparent;}.c-field__prefix,.c-field__suffix {  color: var(--color-text-muted);  white-space: nowrap;}

Note a decisão: quando há adornos, a borda fica no container e o input interno fica “limpo”. Isso evita bordas duplicadas e facilita estados (erro/foco) no nível do Field.

Validação visual: regras claras e estados consistentes

Validação visual escalável significa que qualquer campo inválido é reconhecível, acessível e consistente, sem depender de estilos específicos por tela. Para isso, defina: (1) quais estados existem; (2) quais sinais visuais cada estado aplica; (3) como o estado afeta label, borda, ícones e mensagens; (4) como lidar com foco quando inválido.

Estados recomendados

  • default: campo normal
  • focus: realce de foco (normalmente no input)
  • invalid: erro de validação
  • valid (opcional): sucesso (use com cuidado para não poluir)
  • warning (opcional): atenção sem bloquear
  • disabled e readonly: já tratados no controle

Em geral, “invalid” é obrigatório. “valid” e “warning” só valem se houver casos reais e design definido; caso contrário, geram ruído visual.

CSS do Field para invalid/valid

Centralize no wrapper:

.c-field {  display: grid;  gap: var(--space-2);}.c-field__label {  font-weight: 600;  color: var(--color-text);}.c-field__hint {  margin: 0;  color: var(--color-text-muted);  font-size: var(--font-size-sm);}.c-field__message {  margin: 0;  font-size: var(--font-size-sm);  min-height: 1.25em;  color: var(--color-text-muted);}.c-field--invalid .c-field__label {  color: var(--color-danger);}.c-field--invalid .c-input {  border-color: var(--color-danger);}.c-field--invalid .c-input:focus {  border-color: var(--color-danger);  box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-danger) 25%, transparent);}.c-field--invalid .c-field__message {  color: var(--color-danger);}.c-field--valid .c-input {  border-color: var(--color-success);}.c-field--valid .c-field__message {  color: var(--color-success);}

O min-height na mensagem é um detalhe importante: ele reserva espaço para uma linha de texto e evita que o layout “salte” quando a mensagem aparece. Se você tiver mensagens longas, elas quebram em múltiplas linhas sem deslocar elementos acima do campo.

Integração com atributos nativos (aria-invalid e required)

Para acessibilidade e consistência, use atributos nativos em conjunto com o estado visual:

  • required para campos obrigatórios
  • aria-invalid="true" quando inválido
  • aria-describedby apontando para hint e/ou erro

Exemplo:

<input class="c-input" id="email" required aria-invalid="true" aria-describedby="email-hint email-error" />

Você pode também aplicar estilos com base em [aria-invalid="true"], mas manter o wrapper como fonte de verdade costuma ser mais previsível quando há controles compostos (adornos, selects customizados, etc.).

Mensagens de erro: conteúdo, layout e comportamento

Mensagens de erro escaláveis não são apenas “texto vermelho”. Elas precisam ser: (1) específicas; (2) posicionadas de forma consistente; (3) fáceis de ativar/desativar; (4) compatíveis com leitores de tela; (5) resistentes a textos longos e traduções.

Regras práticas para o texto

  • Descreva o problema e, quando possível, como corrigir: “Informe um e-mail válido (ex.: nome@dominio.com)”.
  • Evite mensagens genéricas repetidas (“Campo inválido”).
  • Para validações de formato, prefira exemplos curtos.
  • Para senha, explique requisitos de forma objetiva (tamanho mínimo, caracteres, etc.).

Componente de mensagem sempre presente

Manter .c-field__message no DOM mesmo quando vazio ajuda em dois pontos: (1) evita reflow e “pulos” ao mostrar erro; (2) facilita aria-describedby fixo. Quando não houver erro, o elemento pode ficar vazio ou receber um texto neutro (ex.: “ ”) dependendo do seu padrão.

Erro no topo do formulário (resumo) sem duplicar lógica

Em formulários longos, um resumo de erros no topo melhora a usabilidade. A ideia é não reinventar estilos por tela: crie um componente de “alerta” e alimente com os mesmos IDs dos campos para permitir links âncora.

<div class="c-form-alert c-form-alert--error" role="alert" aria-live="polite">  <p class="c-form-alert__title">Revise os campos abaixo:</p>  <ul class="c-form-alert__list">    <li><a href="#email">E-mail: informe um e-mail válido</a></li>    <li><a href="#password">Senha: mínimo de 8 caracteres</a></li>  </ul></div>

O resumo não substitui a mensagem no campo; ele complementa. O campo continua sendo a fonte principal de feedback contextual.

Consistência de espaçamento: ritmo vertical e alinhamento entre campos

O maior “cheiro” de CSS em formulários é cada tela ter um espaçamento diferente entre label e input, ou entre campos. Para escalar, defina um ritmo vertical padrão e aplique sempre via um container de formulário e pelo próprio Field.

Padrão de espaçamento interno do Field

O Field já usa gap para espaçar label, controle, hint e mensagem. Isso garante consistência dentro do campo. Evite margens individuais em label/hint/message; prefira um único mecanismo (grid/flex + gap) para reduzir exceções.

Espaçamento entre campos (stack)

Use um wrapper de formulário que empilha fields com um gap fixo. Exemplo:

<form class="c-form">  <div class="c-form__stack">    <div class="c-field">...</div>    <div class="c-field">...</div>    <div class="c-field">...</div>  </div>  <div class="c-form__actions">...</div></form>
.c-form__stack {  display: grid;  gap: var(--space-5);}.c-form__actions {  margin-top: var(--space-6);  display: flex;  gap: var(--space-3);  align-items: center;}

Esse padrão elimina a necessidade de “margin-bottom” em cada campo e facilita ajustes globais de densidade. Se precisar de uma versão compacta, aplique um modificador no form:

.c-form--dense .c-form__stack {  gap: var(--space-3);}.c-form--dense .c-field {  gap: var(--space-1);}

Alinhamento em grids (duas colunas, responsivo)

Para formulários com múltiplas colunas, mantenha o Field como unidade e use um grid no container. Exemplo:

<div class="c-form-grid">  <div class="c-field">...</div>  <div class="c-field">...</div>  <div class="c-field c-form-grid__span-2">...</div></div>
.c-form-grid {  display: grid;  grid-template-columns: 1fr;  gap: var(--space-5);}@media (min-width: 48rem) {  .c-form-grid {    grid-template-columns: 1fr 1fr;  }  .c-form-grid__span-2 {    grid-column: span 2;  }}

O espaçamento entre campos continua consistente porque o gap está no grid. O Field não precisa saber se está em uma ou duas colunas.

Passo a passo prático: montando um kit de formulário reutilizável

Passo 1: Defina a anatomia do Field e padronize o HTML

Escolha uma estrutura única para todos os campos. Garanta que sempre existam: label, control, hint (opcional) e message (sempre presente). Documente o contrato: onde entra o input, onde entram adornos, como aplicar estados.

<div class="c-field">  <label class="c-field__label" for="id">Rótulo</label>  <div class="c-field__control">    <input class="c-input" id="id" />  </div>  <p class="c-field__hint" id="id-hint">Dica (opcional)</p>  <p class="c-field__message" id="id-error"></p></div>

Passo 2: Implemente a base do input e normalize estados essenciais

Crie a classe base do input com foco, disabled e readonly. Evite variações por tipo (text/email/password) no CSS; a diferença deve ser comportamental/HTML, não visual, salvo exceções (ex.: textarea).

.c-textarea {  width: 100%;  font: inherit;  color: var(--color-text);  background: var(--color-surface);  border: 1px solid var(--color-border);  border-radius: var(--radius-sm);  padding: var(--space-2) var(--space-3);  min-height: 7rem;  resize: vertical;}.c-textarea:focus {  border-color: var(--color-focus);  box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-focus) 25%, transparent);  outline: none;}

Passo 3: Centralize validação visual no Field

Implemente .c-field--invalid e, se necessário, .c-field--valid. Faça o estado afetar label, borda do controle e mensagem. Garanta que o foco em estado inválido continue visível e com contraste adequado.

.c-field--invalid .c-field__control--adorned {  border-color: var(--color-danger);}.c-field--invalid .c-field__control--adorned:focus-within {  box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-danger) 25%, transparent);}

:focus-within é especialmente útil quando o input está dentro de um container com borda (caso adornado). Assim, o foco “sobe” para o wrapper visual correto.

Passo 4: Padronize mensagens e reserve espaço

Defina tipografia e altura mínima da mensagem. Decida se hint e message coexistem sempre ou se a hint some quando há erro. Um padrão comum: manter a hint e mostrar o erro abaixo, ou substituir a hint pelo erro para reduzir altura. Se optar por substituir, faça isso no HTML/JS de forma consistente.

.c-field__hint + .c-field__message {  margin-top: calc(var(--space-2) * -1);}

Esse ajuste é opcional e deve ser usado com cuidado; a alternativa mais simples é manter o gap uniforme e aceitar um pouco mais de altura.

Passo 5: Crie composição para grupos (radio/checkbox) sem quebrar o ritmo

Radios e checkboxes geralmente têm layout diferente (controle ao lado do texto). Ainda assim, eles podem viver dentro do mesmo Field, mantendo label/título e mensagem no mesmo padrão.

<div class="c-field c-field--invalid">  <p class="c-field__label" id="terms-label">Termos</p>  <div class="c-field__control" role="group" aria-labelledby="terms-label">    <label class="c-check">      <input class="c-check__input" type="checkbox" />      <span class="c-check__text">Aceito os termos</span>    </label>  </div>  <p class="c-field__message" role="alert">Você precisa aceitar os termos.</p></div>

O Field continua controlando o estado e a mensagem. O componente c-check cuida apenas do alinhamento interno do checkbox.

Passo 6: Garanta consistência de espaçamento no formulário inteiro

Adote .c-form__stack (ou equivalente) como padrão. Evite que telas adicionem margens arbitrárias entre campos. Quando um campo precisar “respirar” mais (ex.: seção), crie um wrapper de seção com espaçamento explícito, em vez de alterar o Field.

<section class="c-form-section">  <h3 class="c-form-section__title">Dados pessoais</h3>  <div class="c-form__stack">...</div></section>
.c-form-section {  display: grid;  gap: var(--space-4);}.c-form-section__title {  margin: 0;  font-size: var(--font-size-lg);}

Casos comuns e como evitar variações perigosas

Campo com contador (textarea)

Um contador de caracteres costuma gerar CSS improvisado. Trate-o como parte do Field, com um slot consistente (ex.: abaixo do controle, alinhado à direita). Exemplo:

<div class="c-field">  <label class="c-field__label" for="bio">Bio</label>  <div class="c-field__control">    <textarea class="c-textarea" id="bio" maxlength="160"></textarea>  </div>  <div class="c-field__meta">    <p class="c-field__hint" id="bio-hint">Máximo de 160 caracteres.</p>    <p class="c-field__counter" aria-live="polite">0/160</p>  </div>  <p class="c-field__message" id="bio-error"></p></div>
.c-field__meta {  display: flex;  justify-content: space-between;  gap: var(--space-3);  align-items: baseline;}.c-field__counter {  margin: 0;  font-size: var(--font-size-sm);  color: var(--color-text-muted);}

Mensagens longas e responsividade

Mensagens de erro podem crescer com traduções. Garanta que o layout suporte quebra de linha e não dependa de alturas fixas. Evite truncar erro com ellipsis. Se for necessário limitar, prefira um padrão de “detalhes” (ex.: link “ver requisitos”) em vez de esconder texto.

Campos inline (ex.: data + hora)

Quando dois controles pertencem ao mesmo conceito, agrupe-os dentro de um único Field para manter uma mensagem única e um label único. Exemplo:

<div class="c-field">  <label class="c-field__label">Agendamento</label>  <div class="c-field__control c-field__control--inline">    <input class="c-input" type="date" />    <input class="c-input" type="time" />  </div>  <p class="c-field__message"></p></div>
.c-field__control--inline {  display: grid;  grid-template-columns: 1fr 1fr;  gap: var(--space-3);}

O estado inválido no Field pode destacar ambos os inputs ao mesmo tempo, o que é mais coerente do que marcar apenas um.

Agora responda o exercício sobre o conteúdo:

Qual prática ajuda a evitar que o layout do formulário salte quando uma mensagem de erro aparece?

Você acertou! Parabéns, agora siga para a próxima página

Você errou! Tente novamente.

Ao manter a área de mensagem no DOM e reservar espaço (por exemplo, com altura mínima), o campo não muda de altura quando o erro surge, evitando reflow e mantendo o ritmo vertical consistente.

Próximo capitúlo

Cards e padrões de conteúdo: hierarquia visual, densidade, slots e composições

Arrow Right Icon
Baixe o app para ganhar Certificação grátis e ouvir os cursos em background, mesmo com a tela desligada.