Web Components semânticos para formulários: labels, validação e acessibilidade
Por John Pires · 11 de março de 2026

Web Components semânticos para formulários: labels, validação e acessibilidade
1. Motivação e trade-offs
Formulários são a espinha dorsal de muitas aplicações web. Componentizar controles de formulário com Web Components parece óbvio: encapsulamento, reutilização e estilo consistente. Porém, quando você introduz Shadow DOM, templates e APIs novas como ElementInternals, surgem problemas semânticos: labels deixam de apontar automaticamente para inputs internos, validação nativa pode parar de funcionar e leitores de tela perdem o contexto do campo.
Esse artigo foca em padrões práticos para construir componentes de formulário que preservem semântica e acessibilidade. Não é uma introdução ao básico de Web Components — partimos do pressuposto que você já conhece Custom Elements e Shadow DOM. O objetivo é apresentar decisões arquiteturais, alternativas e um caminho testado para componentes reais.
Problema central
Resumo do problema: quando um <label> aponta para um <input id="x">, o navegador sabe mapear clique, foco, e o nome acessível. Dentro do Shadow DOM esse vínculo quebra porque o input está fora do contexto do label. Além disso, quando usamos componentes customizados, precisamos mapear estado e valor para que formulários e validação nativos continuem a funcionar.
Trade-offs
Você tem basicamente três caminhos:
- Não usar Shadow DOM e renderizar o controle no light DOM — fácil para semântica, perde encapsulamento.
- Usar Shadow DOM e replicar semântica com
aria-*e manipulação manual de foco e labels — mais código, boa compatibilidade, potencialmente frágil. - Usar Web Components com
ElementInternals(form-associated custom elements) — integração nativa com formulários e API de validação, mas suporte de browser must be checked.
Em resumo: para componentes simples e maximum compatibility, às vezes o light DOM é a solução mais robusta; para controles avançados e encapsulados, prefira form-associated custom elements e providencie fallbacks.
Dica: sempre comece listando requisitos semânticos (labelable, focável, nome acessível, validação) antes de escolher Shadow DOM ou light DOM para um novo componente.
2. Fundamentos técnicos
Antes de ir para exemplos práticos, descrevo os conceitos essenciais que você precisa dominar para não quebrar semântica:
Labeling e Nome Acessível
O mecanismo padrão que conecta <label for="id"> e input funciona no DOM light. Para componentes que usam Shadow DOM, a solução é explicitar o nome acessível usando:
aria-labelledbyapontando para um<label>foraaria-labelpara nomes curtos- ou fornecer propriedades JS que permitam binding do label ao controle interno
Leitores de tela usam Accessible Name Computation — se você fornecer aria-labelledby apontando para o id do label externo, a maioria dos ATs (Assistive Technologies) consegue ler corretamente.
Form association e validação
O padrão de form-associated custom elements permite que um Custom Element se comporte como controle de formulário: participe do submit, receba reset e exponha valor para APIs de constraint validation. Para isso usamos ElementInternals:
this.attachInternals()para obter uma instância deElementInternalsinternals.setFormValue(value)para enviar valor no submitinternals.setValidity()para integrar validação
Caveat: nem todos os navegadores suportam isso ainda (fique atento ao caniuse). Tenha fallback para navegadores sem suporte.
Foco e interações
Shadow DOM impede que um clique no label foque o input interno. Contornos:
- Interceptar clique no próprio elemento personalizado e então focar o controle interno via JS.
- Usar
delegatesFocusao criar Shadow DOM para delegar foco ao primeiro elemento focável no shadow root.
Esses padrões garantem que clicar no <label> continue confortável e previsível.
3. Exemplo 1: caso simples
Objetivo: criar um componente leve que funcione bem com labels sem usar Shadow DOM — escolha pragmática quando semântica e compatibilidade são prioridade.
Como funciona antes do código
Estratégia: o componente cria um <input> dentro do seu conteúdo (light DOM), garante um id estável e expõe propriedades para leitura/escrita do valor. Como o input fica no light DOM, um <label for="..."> continua funcionando.
Prós: sem necessidade de ARIA adicional; comportamento nativo preservado. Contras: menos encapsulamento de estilo.
Abaixo está a implementação mínima.
<!-- wc-text-light.js: cria um componente que renderiza input no light DOM -->
class WcTextLight extends HTMLElement {
constructor() {
super();
this._input = null;
this._id = this.getAttribute('id') || `wc-text-${Math.random().toString(36).slice(2,8)}`;
}
connectedCallback() {
if (!this.querySelector('input')) {
this._input = document.createElement('input');
this._input.type = this.getAttribute('type') || 'text';
this._input.id = this._id;
this._input.name = this.getAttribute('name') || '';
this.appendChild(this._input);
} else {
this._input = this.querySelector('input');
this._input.id = this._id;
}
// proxy value
Object.defineProperty(this, 'value', {
get: () => this._input.value,
set: v => { this._input.value = v; }
});
}
}
customElements.define('wc-text-light', WcTextLight);
O que esse bloco faz: registra o componente <wc-text-light>, cria ou atualiza um input no light DOM e expõe a propriedade value. Como o input está no light DOM, qualquer label for="id" que aponte para o id gerado funciona normalmente.
Uso no HTML:
<label for="myname">Nome</label>
<wc-text-light id="myname" name="username"></wc-text-light>
<!-- O label foca e o form envia o valor normalmente -->
O que acontece: semântica preservada, foco e envio do formulário se comportam como esperado. Limitação: o componente não participa da API de validação se você precisar de integrações mais profundas (como custom validity messages automatizados).
4. Exemplo 2: caso avançado do mundo real
Objetivo: criar um componente encapsulado em Shadow DOM que seja labelable, participe do submit e suporte validação customizada. Estratégia: usar form-associated custom element via ElementInternals, mapear nome acessível com aria-labelledby e tratar foco manualmente.
Antes do código: decisões
Requisitos:
- Encapsulamento visual (Shadow DOM)
- Participar do submit
- Ser labelable e focável quando um
<label for="...">aponta para o componente - Expor validação nativa
Decisão técnica: usar static formAssociated = true; e attachInternals(). Para labels, confiaremos em aria-labelledby apontando para o id do label externo, e também escutaremos eventos de click no próprio custom element para encaminhar foco ao input interno.
Implementação abaixo (funcional em navegadores que suportam form-associated custom elements).
<!-- wc-text-advanced.js: form-associated custom element com Shadow DOM -->
class WcTextAdvanced extends HTMLElement {
static formAssociated = true; // important
constructor() {
super();
this._internals = this.attachInternals();
this._shadow = this.attachShadow({ mode: 'open', delegatesFocus: true });
this._input = document.createElement('input');
this._input.type = this.getAttribute('type') || 'text';
this._input.id = 'inner';
const style = document.createElement('style');
style.textContent = `:host { display: inline-block; } input { font: inherit; }`;
this._shadow.appendChild(style);
this._shadow.appendChild(this._input);
this._onInput = this._onInput.bind(this);
this._onClick = this._onClick.bind(this);
}
connectedCallback() {
this._input.addEventListener('input', this._onInput);
this.addEventListener('click', this._onClick); // forward click from external label
// if host has id, use it for external <label for>
if (!this.id) this.id = this.getAttribute('id') || `wc-adv-${Math.random().toString(36).slice(2,8)}`;
// if aria-labelledby is not provided, try to link automatically to first preceding label
if (!this.hasAttribute('aria-labelledby')) {
const label = document.querySelector(`label[for="${this.id}"]`);
if (label) this.setAttribute('aria-labelledby', label.id || (label.id = `${this.id}-lbl`));
}
// initial value
const initial = this.getAttribute('value') || '';
this._input.value = initial;
this._internals.setFormValue(this._input.value);
}
disconnectedCallback() {
this._input.removeEventListener('input', this._onInput);
this.removeEventListener('click', this._onClick);
}
_onInput() {
// propagate to form
this._internals.setFormValue(this._input.value);
// example of constraint validation: required
if (this.hasAttribute('required') && !this._input.value) {
this._internals.setValidity({ valueMissing: true }, 'Preencha este campo', this._input);
} else {
this._internals.setValidity({});
}
}
_onClick(e) {
// se o usuário clicou no label externo, focar o input interno
this._input.focus();
}
// reflect value property
get value() { return this._input.value; }
set value(v) { this._input.value = v; this._internals.setFormValue(v); }
}
customElements.define('wc-text-advanced', WcTextAdvanced);
O que esse bloco faz: cria um componente encapsulado que participa do submit via internals.setFormValue, trata validação básica e tenta associar automaticamente um label externo. Quando o label tem for="id-do-componente", o componente adiciona aria-labelledby apontando para o label.
Uso no HTML com validação:
<form id="f" action="#" method="post" onsubmit="event.preventDefault(); console.log(new FormData(f).get('email'))">
<label for="email" id="email-label">E-mail</label>
<wc-text-advanced id="email" name="email" required></wc-text-advanced>
<button type="submit">Enviar</button>
</form>
O que acontece: ao submeter, o FormData conterá o campo email com o valor do componente. Se vazio e com required, o componente usa setValidity para sinalizar erro. No entanto, lembre-se: o navegador só exibirá mensagens nativas automáticas de validação para controles nativos — para mensagens customizadas, você pode renderizar UI de erro no shadow DOM.
Fallbacks e feature-detection
Nem todo usuário estará em um navegador com suporte a form-associated custom elements. Faça feature-detection:
if ('attachInternals' in HTMLElement.prototype) {
// podemos usar form-associated pattern
} else {
// fallback: usar light DOM ou polyfill
}
Proposta prática: em navegadores sem suporte, degrade graciosamente para um componente que injeta um input no light DOM (como no Exemplo 1).
5. Debugging e troubleshooting
Nesta seção compilamos erros comuns, armadilhas e dicas práticas para depurar problemas de semântica, foco e validação.
Erros comuns e armadilhas
1) Label não foca o controle: normalmente isso acontece porque o input está no Shadow DOM e você não redirecionou o foco. Solução: interceptar clique no host e chamar input.focus() ou usar delegatesFocus se aplicável.
2) FormData não inclui o valor: esqueceu de usar internals.setFormValue() em um form-associated element. Alternativa: injete um input hidden no light DOM como fallback.
3) Leitores de tela não anunciam o label: verifique aria-labelledby ou aria-label. Se usar for no label apontando para o id do componente, adicione aria-labelledby no host apontando para esse label.
Dicas práticas de debugging
- Use as ferramentas de acessibilidade do navegador (Chrome Accessibility pane, Lighthouse) para inspecionar a Accessible Name Computation.
- Teste com um leitor de tela real (NVDA, VoiceOver) — emulação não substitui teste real.
- Adicione logs no
connectedCallbackpara garantir que IDs e aria-attributes estão sendo setados. - Verifique o suporte a
ElementInternalse construa um fallback simples.
Dica: uma estratégia robusta é implementar ambos: um input no light DOM como fallback e um caminho encapsulado otimizado para browsers modernos. Isso reduz furos de compatibilidade.
Exemplo de fallback que injeta um input hidden caso attachInternals não exista:
if (!('attachInternals' in HTMLElement.prototype)) {
// in component connectedCallback
const fallback = document.createElement('input');
fallback.type = 'hidden';
fallback.name = this.getAttribute('name') || '';
fallback.value = this._input ? this._input.value : '';
this.appendChild(fallback);
}
O que acontece: formulários que não suportam ElementInternals ainda recebem um campo enviado no POST/GET. É uma técnica simples e efetiva para compatibility-first deployments.
Conclusão prática
Construir Web Components semânticos para formulários exige decisões explícitas. Para cada componente, responda: preciso de Shadow DOM? preciso que o navegador trate a validação nativamente? preciso de encapsulamento estrito? Com base nisso escolha entre:
- Light DOM para máxima compatibilidade e semântica nativa
- Shadow DOM + aria/manual handlers para componentes interativos simples
- Form-associated custom elements para integração profunda com formulários (com fallback)
Práticas recomendadas rápidas:
- Priorize o nome acessível: sempre mostre o label para ATs via
aria-labelledbyouaria-label. - Teste com ATs: NVDA/VoiceOver e ferramentas de acessibilidade são obrigatórias.
- Feature-detect: degrade para light DOM ou hidden inputs quando
ElementInternalsnão estiver disponível. - Documente a API: exponha propriedades
value, métodosfocus()e eventos customizados de validação para facilitar integrações.
Implementando esses padrões você terá componentes reutilizáveis, encapsulados e que não sacrificam acessibilidade nem comportamento nativo de formulários. O custo inicial de engenharia compensa em manutenção e consistência de UX.
Assuntos: web-components, acessibilidade, formularios, html-semantico, elementinternals
Leia também
Web Components semânticos: Shadow DOM acessível e indexável para SEO
Padrões práticos para criar Web Components semânticos: mantenha semântica no light DOM, use Shadow DOM só para estilo, ou implemente ElementInternals com…
WebAssembly com Rust para o navegador: compilar e usar com JS (iniciantes)
Guia prático para iniciantes: compile Rust para WebAssembly e consuma no navegador via JS. Passo a passo para tokenizacao, cálculo de scores e integração com…
Artigos SEO-friendly: HTML semântico, JSON-LD e Web Components indexáveis
Demonstração prática de como combinar HTML semântico, JSON-LD e Web Components indexáveis para otimizar artigos: exemplos reais, patterns e armadilhas para…
Form-Associated Custom Elements para formulários semânticos e acessíveis
Guia técnico para construir elementos customizados form-associated que integram corretamente com FormData, validação nativa, labels e acessibilidade. Mostra o…
Formulários condicionais semânticos: padrão acessível com fallback sem JS
Padrão semântico para formulários condicionais que funciona sem JavaScript: radios/:checked, <details> e <noscript> para fallback. Guia prático, código HTML…
Retry resiliente com fetch: AbortController, backoff exponencial e streaming
Padrões práticos para retries resilientes com fetch: combine AbortController para cancelamento, backoff exponencial com jitter para evitar picos, e streaming…