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...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:
requiredpara campos obrigatóriosaria-invalid="true"quando inválidoaria-describedbyapontando 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.