Volver a las notas

Custom elements con ámbito: se acabaron los choques

11 de noviembre de 2026·
webcomponentshtmljavascriptbaseline
Demo en vivoAbrir a pantalla completa

Dos librerías ya pueden usar el mismo nombre de custom element en una página sin romperse entre ellas. Los registros con ámbito (scoped custom element registries) son Baseline desde el 27 de octubre de 2026, cuando salió Firefox 159. Chrome y Edge 146 y Safari 26 ya los tenían.

El problema: un nombre, una página

Hasta ahora, todos los custom elements iban al mismo sitio: window.customElements, el registro global. Ahí los nombres tienen que ser únicos en toda la página. MDN explica la consecuencia: si dos librerías intentan definir <my-button>, una de las dos falla.

Lo puedes ver en la demo de arriba. Cámbiala a "Registro global": Ember UI llama primero a customElements.define('ui-button', …) y gana. Cuando Tide UI intenta lo mismo, el navegador lanza un NotSupportedError, y el registro de abajo muestra el mensaje real de tu navegador. Después, el botón de Tide UI se pinta con la clase de Ember UI, porque en la página solo existe un ui-button. Sale una píldora naranja donde debería haber un botón verde azulado.

Hasta hoy la solución era una convención, no una función del navegador: poner un prefijo a cada etiqueta (<ember-button>, <tide-button>) y esperar que nadie use el mismo. Se complica cuando una página carga dos versiones del mismo sistema de diseño, algo que puede pasar con los microfrontends.

La solución: un registro por shadow root

Ahora puedes crear todos los registros que quieras con new CustomElementRegistry() y asociar cada uno a un shadow root. Según MDN, lo que defines en un registro con ámbito no pasa al global: solo se aplica a los nodos asociados a ese registro. Este es el corazón del modo "Registros con ámbito" de la demo:

const registryA = new CustomElementRegistry();
registryA.define('ui-button', EmberButton);

const registryB = new CustomElementRegistry();
registryB.define('ui-button', TideButton);

const shadowA = emberHost.attachShadow({ mode: 'open', customElementRegistry: registryA });
shadowA.innerHTML = '<ui-button></ui-button>'; // EmberButton

const shadowB = tideHost.attachShadow({ mode: 'open', customElementRegistry: registryB });
shadowB.innerHTML = '<ui-button></ui-button>'; // TideButton

customElements.get('ui-button'); // undefined: el registro global sigue limpio

La misma etiqueta tiene ahora dos clases, dos estilos y dos comportamientos, y el registro global no sabe nada de ninguna. El registro de la demo lo comprueba en directo: shadowA.customElementRegistry === registryA devuelve true.

Asociar el registro más tarde

No siempre tienes el registro listo al crear el shadow root. Puedes pasar customElementRegistry: null, añadir el marcado y asociar el registro después con initialize(). Es el panel "Isla diferida" de la demo:

const island = islandHost.attachShadow({ mode: 'open', customElementRegistry: null });
island.innerHTML = '<ui-button></ui-button>'; // sigue en :not(:defined)

registryB.initialize(island); // ahora es un TideButton

Hasta que llamas a initialize(), los <ui-button> de la isla están en el DOM pero nadie los actualiza, así que puedes seleccionarlos en CSS con :not(:defined). En la demo son las cajas con borde discontinuo. Pulsa el botón y se actualizan en el acto.

La misma idea funciona sin crear el shadow root desde JavaScript. Con shadow DOM declarativo, el atributo shadowrootcustomelementregistry en el <template> le dice al parser que deje el registro vacío. El artículo de Chrome avisa de que el atributo solo hace eso: los elementos los tienes que definir tú, y también tienes que llamar a initialize().

<my-host>
  <template shadowrootmode="open" shadowrootcustomelementregistry>
    <ui-button></ui-button>
  </template>
</my-host>

También hay una versión para un solo elemento: document.createElement('ui-button', { customElementRegistry: registryA }). Según Chrome, un elemento creado así sigue usando ese registro aunque luego lo insertes en otra parte del DOM.

Detalles que conviene saber

  • No se recurre al registro global. Lo he probado en Chromium 151: dentro de un shadow root con registro propio, una etiqueta que solo existe en el registro global se queda sin definir. Cada librería tiene que definir en su registro todos los elementos que usa.
  • Detecta el soporte construyendo un registro, no buscando la clase. En las versiones de Firefox y WebKit con las que lo he probado, CustomElementRegistry existe (es el tipo de window.customElements), pero new CustomElementRegistry() lanza TypeError: Illegal constructor. En WebKit, initialize hasta aparece en el prototipo. La demo usa esta comprobación:
let supportsScoped = false;
try { new CustomElementRegistry(); supportsScoped = true; } catch {}
  • Usa una clase nueva en cada definición. Firefox y WebKit no dejan registrar el mismo constructor con dos nombres en el registro global. La demo crea una clase nueva cada vez que llama a define() para que el plan B no falle.
  • No hay un plan B limpio. Si el navegador no lo soporta, te toca volver a renombrar etiquetas. Es lo que hace la demo en ese caso, con un aviso visible.

Soporte en navegadores

Según webstatus.dev, funciona en Chrome y Edge 146, Firefox 159 y Safari 26, y es Baseline Newly available desde el 27 de octubre de 2026. Las notas de Firefox 159 mencionan también la propiedad customElementRegistry en Document, Element y ShadowRoot. Como es tan reciente, a quien no haya actualizado Firefox en las dos últimas semanas le saldrá el plan B.

Qué vigilar

  • Que las librerías lo adopten. La función solo merece la pena cuando las librerías de componentes te dejen pasarles un registro o creen sus propios registros con ámbito, en lugar de llamar a customElements.define() al importarlas.
  • Firefox sin actualizar. Firefox 159 tiene pocos días. Mira tus analíticas antes de quitar el plan B de los prefijos.
  • Componentes renderizados en servidor. Si generas shadow DOM en el servidor, la vía declarativa con shadowrootcustomelementregistry es la que más te interesa.

Fuentes