Form-Associated Custom Elements para formulários semânticos e acessíveis
Por John Pires · 16 de março de 2026

Form-Associated Custom Elements para formulários semânticos e acessíveis
Este artigo faz revisão técnica sobre como construir componentes customizados que participam de formulários HTML de forma semântica e acessível usando a API Form-Associated Custom Elements (FAE). O foco é prático: primeiro mostro o jeito errado — frequentemente visto em projetos — e depois corrijo com padrões, validações, fallback progressivo e integração com frameworks reais.
1) Quando e por que usar
1.1 Por que preferir elementos form-associated
Elementos form-associated permitem que um componente customizado se comporte como controles nativos do formulário: participar de submissão, expor valor via FormData, integrar com labels, aplicar constraint validation (required, pattern), responder a disabled/reset e ser reconhecido por leitores de tela quando corretamente implementado. Usar essa API melhora extremamente a semântica sem quebrar a experiência de formulários existentes.
1.2 Quando evitar ou adiar
Se seu componente é apenas um widget visual que não precisa participar de formulários (por exemplo, um decorador visual sem valor) não force form-associated. Também adie quando o suporte do navegador não for crítico e você puder usar um input nativo ou realizar integração via APIs do framework com menor custo.
Use o recurso quando precisar que o componente entregue um valor semântico como parte de uma submissão ou quando quiser reaproveitar validação nativa do navegador.
2) Comparação com alternativas
2.1 Jeito errado: replicar comportamento sem API
O erro mais comum é implementar um componente que só “parece” um input, mas não integra com o formulário. Isso quebra acessibilidade, não envia dados automaticamente e falha em integração com labels e leitores de tela.
Exemplo de erro: um componente que mantém estado interno e dispara um evento personalizado, mas não participa do FormData nem responde a reset/disabled.
<form id="f1" action="/echo" method="post">
<my-bad-input name="bad"></my-bad-input>
<button type="submit">Enviar</button>
</form>
<script>
class MyBadInput extends HTMLElement {
constructor(){
super();
this._value = '';
this.attachShadow({mode:'open'}).innerHTML = `
<input type="text"/>
`;
this.shadowRoot.querySelector('input').addEventListener('input', e => {
this._value = e.target.value;
this.dispatchEvent(new CustomEvent('change'));
});
}
get value(){ return this._value; }
}
customElements.define('my-bad-input', MyBadInput);
</script>
O que esse código faz: cria um componente visual com um input interno. O que acontece: o navegador não inclui "bad" no FormData; labels e validação nativa não funcionam corretamente. Resultado: integração frágil e comportamento inesperado.
2.2 Jeito certo: usar form-associated
A alternativa correta é implementar a API form-associated. Isso transforma o elemento em um controle de formulário nativo, permitindo internals.setFormValue para sincronizar com FormData e callbacks para reset/disabled.
<form id="f2" action="/echo" method="post">
<label for="good">Nome</label>
<my-good-input id="good" name="good" required></my-good-input>
<button type="submit">Enviar</button>
</form>
<script>
class MyGoodInput extends HTMLElement {
static formAssociated = true; // torna o elemento form-associated
constructor(){
super();
this._internals = this.attachInternals();
this.attachShadow({mode:'open'}).innerHTML = `
<input part="native" aria-label="nome"/>
`;
this._input = this.shadowRoot.querySelector('input');
this._input.addEventListener('input', e => this._onInput(e));
}
_onInput(e){
this._value = e.target.value;
// sincroniza com o FormData
this._internals.setFormValue(this._value);
}
// expõe API pública
get value(){ return this._value || ''; }
set value(v){ this._value = v; this._internals.setFormValue(v); this._input.value = v; }
// callbacks do ciclo de vida do form
formResetCallback(){ this.value = ''; }
formDisabledCallback(disabled){ this._input.disabled = disabled; }
}
customElements.define('my-good-input', MyGoodInput);
</script>
O que esse código faz: implementa FAE, sincroniza FormData, expõe value e responde a reset/disabled. O que acontece: comportamento consistente com inputs nativos; validação e labels funcionam melhor.
3) Implementação progressiva
3.1 Feature detection e fallback
Nem todos os navegadores suportam ElementInternals/FAE. Implementar fallback evita que a funcionalidade quebre em clientes antigos. Padrão: detectar window.ElementInternals e attachInternals, e criar um input oculto name-synced para submissão quando FAE não estiver disponível.
<!-- Exemplo: estratégia progressiva com fallback para browsers antigos -->
<my-progressive-input name="email" id="email"></my-progressive-input>
<script>
class MyProgressiveInput extends HTMLElement {
static formAssociated = true; // tenta usar
constructor(){
super();
this.attachShadow({mode:'open'}).innerHTML = `
<input type="email" part="native"/>
`;
this._input = this.shadowRoot.querySelector('input');
// Fallback: when ElementInternals not available
if(!('attachInternals' in Element.prototype)){
this._fallback = document.createElement('input');
this._fallback.type = 'hidden';
this._fallback.name = this.getAttribute('name');
this.appendChild(this._fallback);
this._input.addEventListener('input', () => this._fallback.value = this._input.value);
} else {
this._internals = this.attachInternals();
this._input.addEventListener('input', () => this._internals.setFormValue(this._input.value));
}
}
}
customElements.define('my-progressive-input', MyProgressiveInput);
</script>
O que esse código faz: tenta usar FAE; se não suportado, cria um input hidden para incluir o valor no FormData. O que acontece: a experiência degrade graciosamente sem quebrar a submissão.
3.2 Sincronização de atributos e propriedades
Um erro comum é tratar attrs e props separadamente. Sempre sincronize ambos. Refletir atributos esperados (name, disabled, required) para a implementação garante interoperabilidade com ferramentas que manipulam DOM via atributos.
Exemplo de boas práticas:
// dentro do elemento form-associated
observedAttributes(){ return ['name','disabled','required']; }
attributeChangedCallback(name, oldVal, newVal){
if(name === 'name' && this._fallback) this._fallback.name = newVal;
if(name === 'disabled') this._input.disabled = newVal !== null;
if(name === 'required') this._internals?.setValidity(this._validate() ? {} : {valueMissing: true});
}
O que esse código faz: mantém atributos sincronizados entre fallback, shadow DOM e internals. O que acontece: o componente se comporta de forma previsível quando manipulado pelo DOM ou por frameworks.
4) Integracao com ecossistema real
4.1 Integração com React / JSX
React não sabe automaticamente como lidar com elementos customizados para forms. Evite controlar via value prop como em inputs nativos. Prefira o padrão uncontrolled com refs ou expor eventos (input/change) e valores via propriedades.
Exemplo de uso em React (JSX) — a ideia é usar ref para ler o value antes do submit:
// React: uso do elemento form-associated via ref
function FormReact(){
const ref = React.useRef(null);
const onSubmit = (e) => {
e.preventDefault();
const fd = new FormData(e.target); // o elemento form-associated deve participar
// leitura direta via ref quando precisar
console.log('formData name:', fd.get('username'));
console.log('value via ref:', ref.current.value);
};
return (
<form onSubmit={onSubmit}>
<my-good-input ref={ref} name="username" />
<button type="submit">Enviar</button>
</form>
);
}
O que esse código faz: demonstra submissão de um formulário React contendo um elemento form-associated. O que acontece: se o componente implementa FAE corretamente, FormData incluirá o valor; use ref para acessar programaticamente quando necessário.
4.2 Integração com validação e testes e2e
Em testes end-to-end (Cypress/Playwright) assegure rotas de seleção baseadas em attributes ou roles. Exponha atributos data-testid se necessário. Para validação, use internals.setValidity e internals.reportValidity para integrar com APIs nativas e facilitar asserts nos testes.
Boas práticas de testes:
- Testar submissão com FormData para garantir inclusão do name/valor.
- Testar comportamento de reset e disabled.
- Testar interação com labels e foco.
5) Checklist de qualidade para producao
5.1 Validações funcionais
Checklist objetivo:
- Tem static formAssociated = true quando necessário?
- Usa attachInternals e internals.setFormValue para sincronização?
- Responde a formResetCallback e formDisabledCallback?
- Expose value property e reflete atributo name?
- Implementa constraint validation (setValidity) corretamente?
5.2 Acessibilidade e semântica
Checklist de acessibilidade:
- Label associado via for ou wrapper e testes com leitores de tela.
- Uso correto de aria-* apenas quando necessário (não esconda estado nativo).
- Keyboard interaction consistente: foco, Enter/Escape, etc.
Blockquote:
Priorize o comportamento semântico e deixe o visual para o CSS. Elementos que falham em semântica criam mais dívida técnica que resolver o estilo localmente.
Erros comuns, armadilhas e dicas práticas
Erros comuns
1) Não chamar internals.setFormValue: o valor não entra no FormData. 2) Esquecer de refletir name para fallback hidden input. 3) Não implementar formResetCallback — depois de reset o valor permanece. 4) Não sincronizar atributo disabled com o estado interno — forms nativos dependem disso.
Dicas práticas
1) Sempre exponha uma API pública simples: value, name, checkValidity(), reportValidity(). 2) Use roles e parts para styling e integração com ferramentas de design. 3) Documente quando o elemento exige polyfill e forneça fallback.
Exemplos reais (workflow de code review: errado -> certo)
Exemplo 1: componente sem suporte a labels e sem enviar valor (errado)
Contexto: componente que estiliza input mas não participa do formulário. Problema: não envia dados, label não foca automaticamente.
<my-bad>...</my-bad>
// O que acontece: o form não recebe valor; label[for] não conecta; leitores de tela ignoram o controle.
Exemplo 2: correção completa com form-associated
Contexto: transformar o my-bad em my-good implementando FAE, callbacks e validação.
<form id="signup">
<label for="email">Email</label>
<my-email id="email" name="email" required></my-email>
<button type="submit">Enviar</button>
</form>
<script>
class MyEmail extends HTMLElement{
static formAssociated = true;
constructor(){
super();
this._internals = this.attachInternals();
this.attachShadow({mode:'open'}).innerHTML = `
<input type="email"/>
`;
this._input = this.shadowRoot.querySelector('input');
this._input.addEventListener('input', e => this._sync(e.target.value));
}
_sync(v){
this._value = v;
this._internals.setFormValue(v);
// validação básica
if(this.hasAttribute('required') && !v) {
this._internals.setValidity({valueMissing:true}, 'Campo obrigatório');
} else {
this._internals.setValidity({});
}
}
formResetCallback(){ this._input.value = ''; this._sync(''); }
formDisabledCallback(disabled){ this._input.disabled = disabled; }
get value(){ return this._value || ''; }
}
customElements.define('my-email', MyEmail);
</script>
// O que acontece: o valor participa do FormData; required é respeitado e reportado; label foca o controle.
Esse trecho corrige os problemas listados: o elemento agora é um participante completo do formulário.
Conclusão prática
Form-associated custom elements permitem que Web Components atuem como controles nativos, garantindo semântica, acessibilidade e integração com o ecossistema. A regra de ouro em code review é: se o controle aparenta ser um input, ele deve se comportar como tal. Se faltar algum comportamento nativo (submissão, validação, reset, disabled, associação com label), trate como bug crítico.
Checklist rápido final:
- Use static formAssociated = true quando o componente tiver valor semântico.
- Chame attachInternals e internals.setFormValue para sincronizar FormData.
- Implemente formResetCallback e formDisabledCallback.
- Forneça fallback com input hidden quando FAE não for suportado.
- Exponha API pública simples (value, checkValidity, reportValidity).
Implementar corretamente reduz bugs, facilita testes e melhora acessibilidade. Em revisões de código, trate qualquer controle de formulário customizado como parte crítica da camada de dados — falhas aqui causam perda de conversão e problemas de conformidade de acessibilidade.
Conclua: não se contente com um visual correto; garanta comportamento semântico. Em produção, foque em testes de integração que validem FormData, reset, disabled e interações com labels.
Assuntos: web-components, forms, acessibilidade, html-semantico, form-associated, HTML, React
Leia também
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…
Pool de concorrência com Async Iterators e AbortSignal
Implementar um pool de concorrência com Async Iterators e AbortSignal para controlar tarefas assíncronas, permitir cancelamento cooperativo e aplicar…
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…
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…
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…
Web Components semânticos para formulários: labels, validação e acessibilidade
Padrões práticos para construir Web Components de formulário mantendo labels, validação e acessibilidade: quando usar Shadow DOM, ElementInternals e fallbacks…