Construa Controles de Formulário Reutilizáveis com Web Components
Crie um controle de formulário reutilizável com Web Components e ElementInternals. Adicione envio, validação, rótulos, reset e restauração nativos.
Um elemento personalizado que envolve um <input> dentro de um Shadow DOM é invisível para o <form> pai: seu valor nunca chega ao FormData, a validação nativa o ignora e um reset não o afeta. A solução são duas linhas — static formAssociated = true mais this.attachInternals() no construtor — e a API ElementInternals que essas linhas desbloqueiam. Este artigo constrói um controle <text-field> completo que envia, valida, rotula, reseta e restaura como um input nativo, usando apenas a API da plataforma.
Com o lançamento do Safari 16.4 em março de 2023, os web components atingiram um marco em sua capacidade de interagir com o elemento de formulário usando a API ElementInternals; antes disso, elementos input localizados dentro de um Shadow DOM não eram detectáveis pelos formulários, o que significava que não seriam validados no envio e seus dados não seriam incluídos no objeto FormData. Essa questão de suporte está agora resolvida, portanto o artigo ignora a preocupação do “posso usar isso ainda?” e foca na receita completa que nenhum dos tutoriais mais antigos monta de ponta a ponta.
Principais Conclusões
- Duas linhas transformam qualquer elemento personalizado autônomo em um controle de formulário:
static formAssociated = truedeclara a intenção, ethis.attachInternals()retorna o objetoElementInternalsque conecta seu elemento ao formulário pai. - A partir de setembro de 2025, os elementos personalizados associados a formulários são Baseline Amplamente Disponíveis — suportados no Chrome desde a versão 77, Edge desde a 79, Firefox desde a 98 e Safari desde a 16.4 — portanto, a antiga ressalva “isso já é suportado?” não se aplica mais.
- O valor de um controle só chega ao
FormDataquando você chamainternals.setFormValue(value); passarnullremove o elemento do envio completamente. setValidity()bloqueia o envio e aplica:invalid, mas uma validação acessível também requer uma mensagem visível vinculada comaria-describedby— caso contrário, o formulário recusa o envio silenciosamente.- Dos dois modos de restauração de estado definidos pela especificação, apenas
"restore"é acionado de forma confiável nos navegadores disponíveis;"autocomplete"está especificado, mas não é implementado de forma confiável.
Por que o Shadow DOM quebra a participação nativa em formulários
Um <input> nativo participa de um formulário apenas quando é descendente do <form> na mesma árvore DOM. O Shadow DOM é uma árvore separada, portanto um input renderizado dentro do shadow root de um elemento personalizado é estruturalmente invisível para o formulário: o navegador nunca o adiciona a form.elements, nunca coleta seu valor no FormData e nunca executa a validação de restrições contra ele. Este é o trade-off de encapsulamento funcionando conforme projetado — e o problema exato que os elementos personalizados associados a formulários (FACE, do inglês form-associated custom elements) existem para resolver.
A resposta da plataforma é o ElementInternals. O método HTMLElement.attachInternals() retorna um objeto ElementInternals; esse método permite que um elemento personalizado participe de formulários HTML, e a interface fornece utilitários para trabalhar com esses elementos da mesma forma que você trabalharia com qualquer elemento de formulário HTML padrão, além de expor o Modelo de Objeto de Acessibilidade ao elemento. O FACE não é mais um recurso de fronteira: os elementos personalizados associados a formulários são Baseline Amplamente Disponíveis desde 27/09/2025, suportados no Chrome 77 (2019), Edge 79 (2020), Firefox 98 (2022) e Safari 16.4, lançado em 27/03/2023. Um element-internals-polyfill da comunidade ainda existe para engines legadas, mas com suporte Baseline em navegadores evergreen, a maioria dos projetos não precisa mais dele.
Uma restrição importante: adicionar uma propriedade estática formAssociated com valor true torna um elemento personalizado autônomo um elemento personalizado associado a formulário. O elemento deve estender HTMLElement diretamente — não uma subclasse nativa — e attachInternals() lança um NotSupportedError se o elemento não for um elemento personalizado.
O elemento personalizado associado a formulário mínimo
Discover how at OpenReplay.com.
O menor controle útil declara formAssociated, obtém seus internals e envia seu valor ao formulário por meio de um setter. Todo o resto é construído sobre esse esqueleto.
class TextField extends HTMLElement {
static formAssociated = true;
static observedAttributes = ['value', 'required'];
#internals;
#value = '';
constructor() {
super();
this.#internals = this.attachInternals();
}
get value() { return this.#value; }
set value(v) {
this.#value = v ?? '';
this.#internals.setFormValue(this.#value);
}
get form() { return this.#internals.form; }
get name() { return this.getAttribute('name'); }
get type() { return this.localName; }
}
customElements.define('text-field', TextField);
setFormValue() é a chamada fundamental aqui. O valor de um controle só chega ao FormData quando você invoca internals.setFormValue(value); passe null e o elemento é removido do envio completamente. O padrão de setter garante que toda atribuição a .value — por script, um atributo ou entrada do usuário — ressincronize o valor enviado. Uma vez associado, o elemento se comporta como um nativo: a coleção elements do formulário inclui todos os <button>, <fieldset>, <input>, <object>, <output>, <select>, <textarea> e elementos personalizados associados a formulários vinculados ao formulário.
Se você só precisa injetar dados arbitrários em um envio — sem construir um controle reutilizável — o evento formdata com menor overhead é suficiente: escute-o no formulário e chame event.formData.append(...). Use ElementInternals quando estiver construindo um controle que precise validar, rotular, resetar e restaurar.
Renderizar e sincronizar o input interno
O controle precisa de um <input> interno cujo valor flua de volta pelo setter público, e cujos eventos input/change sejam redespachados do host para que os listeners em <text-field> se comportem como em um campo nativo. Como um shadow root só pode ser anexado uma vez, a configuração é protegida para que uma desconexão seguida de reconexão não chame attachShadow() uma segunda vez (o que lançaria um erro):
connectedCallback() {
if (!this.shadowRoot) {
const root = this.attachShadow({ mode: 'open', delegatesFocus: true });
root.innerHTML = `<input part="input" />`;
this.input = root.querySelector('input');
this.input.addEventListener('input', () => {
this.value = this.input.value; // ressincroniza setFormValue
this.#validate(); // validade em tempo real
});
this.input.addEventListener('change', (e) => {
this.dispatchEvent(new e.constructor(e.type, e));
});
}
this.value = this.getAttribute('value') ?? '';
this.#validate();
}
Eventos change nativos não cruzam a fronteira do shadow, portanto clonar o evento com new e.constructor(e.type, e) e redespachá-lo do host o reemite no light DOM onde o código pai pode ouvi-lo. Conduzir this.value pelo listener input do input mantém o valor do FormData enviado atualizado a cada tecla pressionada.
Rotulagem, delegação de foco e tabindex
Um <label for="..."> nativo deve focar o controle quando clicado. Para um elemento personalizado associado a formulário, esse comportamento vem de delegatesFocus: true no shadow root. Definir delegatesFocus: true é o que faz um clique em <label for> mover o foco para o input interno, exatamente como um campo nativo — sem isso, clicar no label não faz nada e focar o host não alcança o input. Como um FACE é um elemento rotulável, você escreve a marcação exatamente como faria para um input nativo:
<form>
<label for="email">Email</label>
<text-field id="email" name="email" required></text-field>
</form>
A associação de labels para leitores de tela em controles personalizados tem sido historicamente inconsistente entre diferentes combinações de navegadores e tecnologias assistivas, portanto teste com a AT alvo e mantenha um <label> interno ao shadow como fallback se você suportar uma ampla matriz. Com delegatesFocus definido, geralmente não é necessário gerenciar tabindex manualmente — o foco é delegado ao input interno, que já está na ordem de tabulação.
Validação com setValidity() e os ValidityStateFlags completos
ElementInternals.setValidity() espelha o sistema nativo de validação de restrições. Você passa um objeto ValidityStateFlags, uma mensagem opcional legível por humanos e um elemento âncora opcional; o navegador então aplica a pseudo-classe :invalid e bloqueia o envio. Com essa configuração, a pseudo-classe :invalid é aplicada automaticamente ao elemento sempre que um flag é definido. Passar um objeto vazio {} limpa todos os erros.
Os flags mapeiam um a um para os motivos de validade nativos:
| Flag | Análogo nativo |
|---|---|
valueMissing | required sem valor |
typeMismatch | valor incorreto para o type (ex.: email) |
patternMismatch | valor falha no pattern |
tooLong / tooShort | excede maxlength / abaixo de minlength |
rangeUnderflow / rangeOverflow | abaixo de min / acima de max |
stepMismatch | valor não alinhado ao step |
badInput | entrada não parseável |
customError | erro definido pelo autor via mensagem personalizada |
Valide a cada evento input para feedback em tempo real e exponha os métodos padrão (checkValidity(), reportValidity(), validity, validationMessage) para que os chamadores possam tratar seu controle como qualquer campo nativo:
#validate() {
const flags = {};
let message = '';
if (this.hasAttribute('required') && !this.#value) {
flags.valueMissing = true;
message = 'Este campo é obrigatório.';
}
this.#internals.setValidity(flags, message, this.input);
this.#renderError(message);
}
checkValidity() { return this.#internals.checkValidity(); }
reportValidity() { return this.#internals.reportValidity(); }
get validity() { return this.#internals.validity; }
get validationMessage() { return this.#internals.validationMessage; }
setValidity() é apenas metade da validação acessível: o navegador bloqueará o envio e aplicará :invalid, mas a menos que você também renderize uma mensagem visível vinculada com aria-describedby, o usuário terá um formulário que recusa o envio silenciosamente. O passo #renderError é abordado na seção de acessibilidade.
Os quatro callbacks do ciclo de vida do formulário
Elementos personalizados associados a formulários recebem quatro callbacks de reação extras além do ciclo de vida padrão de elementos personalizados. Estes são a peça que define a completude e que a maioria dos tutoriais ignora. formAssociatedCallback(form) é chamado quando o formulário associado muda; formResetCallback() é chamado quando o formulário está sendo resetado e o elemento deve limpar o valor definido pelo usuário; formDisabledCallback(isDisabled) é chamado quando o estado desabilitado muda; e formStateRestoreCallback(state, reason) é chamado quando o navegador restaura o estado do elemento (razão "restore") ou realiza o preenchimento automático (razão "autocomplete").
| Callback | Disparado quando | Argumento | Corpo típico |
|---|---|---|---|
formAssociatedCallback | elemento ganha/perde um formulário proprietário | o novo form (ou null) | configurar estado dependente do formulário |
formDisabledCallback | o estado desabilitado do host ou fieldset ancestral muda | boolean | espelhar no input interno |
formResetCallback | o formulário é resetado | nenhum | restaurar o valor padrão |
formStateRestoreCallback | navegação para frente/trás, recarregamento, preenchimento automático | estado restaurado, razão | reidratar a partir do estado salvo |
formDisabledCallback(disabled) {
this.input.disabled = disabled;
}
formResetCallback() {
this.value = this.getAttribute('value') ?? '';
this.#internals.setValidity({});
this.#renderError('');
}
formStateRestoreCallback(state, reason) {
if (reason === 'restore') this.value = state;
}
Os atributos disabled e readonly têm significado específico na especificação aqui: o atributo disabled torna um elemento personalizado associado a formulário não interativo e impede que seu valor de envio seja submetido, enquanto o atributo readonly especifica que o elemento está impedido de participar da validação de restrições.
Restauração de estado, conforme a especificação
setFormValue() aceita um segundo argumento opcional — setFormValue(value, state) — onde state é uma representação exclusiva do cliente que não é enviada ao servidor, útil para restaurar um estado interno mais rico do que o simples valor de envio. A especificação agora é explícita sobre o que o callback de restauração recebe: quando o agente de usuário atualiza o valor de um elemento personalizado associado a formulário em nome de um usuário ou como parte da navegação, seu formStateRestoreCallback é chamado, recebendo o novo state e uma string indicando a razão, "autocomplete" ou "restore". Uma inconsistência anterior na especificação causou divergência real — o Chromium chamava o callback com o valor enquanto o WebKit o chamava com o state — mas isso foi resolvido em favor do state. Se você suportar versões mais antigas do Chromium/Edge, trate o argumento de forma defensiva.
Apenas "restore" é confiável hoje. O Firefox implementou o suporte a formStateRestoreCallback, mas observa que 'autocomplete' para elementos personalizados permanece sem suporte. Trate "restore" (navegação para trás e recarregamento) como o único modo no qual você pode confiar em todos os engines.
Acessibilidade feita corretamente
Definir a validade não é o mesmo que comunicá-la. Para tornar os erros perceptíveis, renderize uma mensagem dentro do shadow root, vincule-a ao input interno com aria-describedby e marque-a como uma região ativa para que a tecnologia assistiva a anuncie:
#renderError(message) {
let err = this.shadowRoot.querySelector('.error');
if (!err) {
err = document.createElement('div');
err.className = 'error';
err.id = 'err';
err.setAttribute('role', 'alert');
err.setAttribute('aria-live', 'assertive');
this.shadowRoot.append(err);
this.input.setAttribute('aria-describedby', 'err');
}
err.textContent = message;
}
Como tanto o input quanto o erro vivem no mesmo shadow root, aria-describedby resolve corretamente dentro dessa árvore. A própria semântica ARIA do elemento host pode ser definida por meio das propriedades ElementInternals — e essa superfície agora tem amplo suporte, incluindo ElementInternals.role e as propriedades de reflexão aria*.
Um modo de falha comum em produção é o controle meio acabado que parece pronto mas falha silenciosamente — e esses são exatamente os bugs que a reprodução de sessão revela, pois não lançam nenhum erro no console. A reprodução mostra um usuário clicando em enviar repetidamente contra um controle que reportou valueMissing sem nenhuma mensagem visível; um clique no label que não vai a lugar nenhum porque delegatesFocus nunca foi definido; ou um payload enviado sem um campo porque o setter nunca chamou setFormValue. Mais uma armadilha que vale uma nota de revisão: colocar um input nativo required em um slot de um elemento personalizado deixa o controle no light DOM ativo no formulário junto com o host, produzindo participação dupla. Mantenha controles nativos fora dos slots quando o host for ele mesmo associado a formulário.
Usando isso dentro de um framework
As integrações de frameworks para elementos personalizados associados a formulários envolvem exatamente a API ElementInternals bruta mostrada aqui. O Stencil oferece suporte de primeira classe a partir da v4.5.0 por meio de um decorator @AttachInternals, e seu formStateRestoreCallback também recebe um segundo argumento mode, "restore" ou "autocomplete", indicando a razão da restauração. O Lit não inclui FACE nativamente — você adiciona um pequeno mixin FormAssociated você mesmo ou importa um da comunidade — mas esse mixin envolve o mesmo flag formAssociated, attachInternals(), setFormValue() e os callbacks de ciclo de vida mostrados aqui. Aprender a API da plataforma diretamente significa que você pode ler, depurar ou substituir qualquer um desses wrappers.
Monte as peças — o setter de valor, a sincronização do input interno, delegatesFocus, setValidity com uma mensagem role="alert" vinculada e todos os quatro callbacks de ciclo de vida — e você terá um único <text-field> que envia, valida, reseta e restaura de forma indistinguível de um input nativo. Coloque-o em um <form>, conecte um <label for> e o próximo passo concreto é verificar o ciclo completo: envie o formulário, leia o valor de volta de new FormData(form) e confirme que a validação de restrições bloqueia um campo obrigatório vazio.
Perguntas Frequentes
Qual é a diferença entre o evento formdata e ElementInternals para adicionar valores a um formulário?
O evento formdata injeta dados arbitrários em um envio: você o escuta no formulário e chama event.formData.append() para adicionar campos. ElementInternals constrói um controle reutilizável que participa completamente do formulário, incluindo validação de restrições, rotulagem, reset e restauração de estado por meio de setFormValue e setValidity. Use o evento formdata para injeção rápida de dados pontuais; use ElementInternals quando precisar de um controle que se comporte como um input nativo.
Por que attachInternals() lança um NotSupportedError?
attachInternals() lança um NotSupportedError quando o elemento não é um elemento personalizado autônomo. Elementos personalizados associados a formulários devem estender HTMLElement diretamente, não uma subclasse nativa como HTMLInputElement, e a classe deve ser registrada por meio de customElements.define antes da instanciação. Chamar attachInternals() mais de uma vez no mesmo elemento também lança um erro. Coloque a chamada uma única vez no construtor em uma classe que estende HTMLElement para evitar o erro.
delegatesFocus interfere no gerenciamento manual de tabindex em um elemento personalizado associado a formulário?
Com delegatesFocus definido como true no shadow root, geralmente não é necessário gerenciar tabindex manualmente. O foco é delegado ao primeiro elemento focalizável dentro do shadow root, que é o input interno já na ordem de tabulação, portanto o elemento host encaminha o foco automaticamente. Um clique em label-for e a tabulação pelo teclado ambos alcançam o input interno. Adicionar um tabindex manual no host pode criar uma parada de tabulação duplicada, portanto omita-o quando delegatesFocus gerenciar o roteamento.
O que acontece quando você chama setFormValue com null?
Chamar internals.setFormValue(null) remove o elemento do envio do formulário completamente: seu nome e valor são excluídos do objeto FormData que o formulário produz. Isso difere de passar uma string vazia, que envia o campo com um valor vazio. Use null para excluir deliberadamente um controle, como um campo desabilitado ou somente leitura, e passe uma string de valor real ou FormData sempre que o controle deva contribuir para o envio.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k