JavaScript Shadow DOM
El Shadow DOM permite adjuntar un árbol DOM encapsulado a un elemento para aislar su marcado, estilos y scripts del resto de la página. Aprende a crear shadow roots abiertos y cerrados, definir estilos con alcance, usar slots y construir componentes reutilizables.
El Shadow DOM es un bloque de construcción fundamental de los Web Components que permite adjuntar un árbol DOM encapsulado y un ámbito de estilos aislado a un elemento. Esta guía explica qué es el Shadow DOM, por qué es importante, cómo crear shadow roots abiertos y cerrados, definir estilos con alcance, proyectar contenido con slots y ensamblar todo dentro de un elemento personalizado reutilizable.
¿Qué es el Shadow DOM?
El Shadow DOM permite adjuntar un subárbol DOM separado y oculto a un elemento. El marcado y los estilos dentro de ese subárbol están encapsulados: no se filtran hacia afuera, y los estilos globales no se filtran hacia adentro. Esto resuelve uno de los problemas más antiguos del desarrollo front-end: la colisión de CSS global e IDs entre componentes.
Conviene definir algunos términos desde el principio:
- Shadow host — el elemento ordinario al que se adjunta el árbol shadow.
- Shadow root — el nodo raíz del árbol oculto, devuelto por
attachShadow(). - Shadow tree — el DOM dentro del shadow root.
- Light DOM — los hijos ordinarios del elemento, escritos en marcado normal; estos pueden proyectarse en el shadow tree a través de slots.
El propio navegador utiliza Shadow DOM internamente: los controles de un elemento <video> o <input type="range"> viven en un shadow tree al que no puedes acceder, lo que explica precisamente por qué sus partes internas nunca colisionan con tu CSS.
En el ejemplo siguiente, dos elementos comparten la clase shadow-box, pero cada uno mantiene su propio estilo porque uno vive en el documento principal y el otro dentro de un shadow root.
<head>
<style>
.shadow-box {
padding: 10px;
border: 1px solid #000;
background-color: lightcoral;
color: white;
}
</style>
</head>
<body>
<div class="shadow-box">This is styled by the main document</div>
<div id="host"></div>
<script>
// Create a shadow root
const hostElement = document.getElementById('host');
const shadowRoot = hostElement.attachShadow({ mode: 'open' });
// Attach shadow DOM content
shadowRoot.innerHTML = `
<style>
.shadow-box {
padding: 10px;
border: 1px solid #000;
background-color: lightblue;
color: black;
}
</style>
<div class="shadow-box">Hello, Shadow DOM!</div>
`;
</script>
</body>En este ejemplo, hay dos elementos con el mismo nombre de clase shadow-box. El primer elemento recibe los estilos del CSS del documento principal, mientras que el segundo recibe los estilos del CSS del Shadow DOM. Como puedes ver, los estilos definidos en el Shadow DOM no afectan a los elementos del documento principal y viceversa. Esto demuestra la encapsulación que proporciona el Shadow DOM, lo que permite crear componentes aislados y reutilizables sin preocuparse por conflictos de estilos.
Creación de un Shadow Root
Para crear un shadow root, utiliza el método attachShadow en un elemento. El shadow root puede ser open (abierto) o closed (cerrado). Un shadow root open es accesible desde JavaScript externo al shadow tree, mientras que uno closed no lo es.
Shadow Root Abierto
Un shadow root abierto permite el acceso y la manipulación desde JavaScript externo. En el ejemplo siguiente, manipulamos el contenido de texto dentro del shadow root después de crearlo.
<body>
<div id="open-shadow-host"></div>
<button id="open-shadow-btn">Change Shadow Content</button>
<script>
const openShadowHost = document.getElementById('open-shadow-host');
const openShadowRoot = openShadowHost.attachShadow({ mode: 'open' });
openShadowRoot.innerHTML = `
<style>
.shadow-content {
color: blue;
padding: 10px;
border: 1px solid black;
}
</style>
<div class="shadow-content">This is an open shadow root</div>
`;
document.getElementById('open-shadow-btn').addEventListener('click', () => {
openShadowRoot.querySelector('.shadow-content').textContent = 'Open Shadow Root content updated!';
});
</script>
</body>En este ejemplo, se proporciona un botón para cambiar el contenido del Shadow DOM. Como el shadow root es abierto, podemos acceder a su contenido y manipularlo desde el documento principal.
Shadow Root Cerrado
Un shadow root cerrado restringe el acceso desde scripts externos, proporcionando una mejor encapsulación. En el ejemplo siguiente, intentamos manipular el contenido de texto dentro del shadow root después de crearlo, pero no es posible ya que está closed.
<body>
<div id="closed-shadow-host"></div>
<button id="closed-shadow-btn">Try to Change Shadow Content</button>
<script>
const closedShadowHost = document.getElementById('closed-shadow-host');
const closedShadowRoot = closedShadowHost.attachShadow({ mode: 'closed' });
closedShadowRoot.innerHTML = `
<style>
.shadow-content {
color: red;
padding: 10px;
border: 1px solid black;
}
</style>
<div class="shadow-content">This is a closed shadow root</div>
`;
// closedShadowHost.shadowRoot is null for closed roots, so this throws a TypeError
document.getElementById('closed-shadow-btn').addEventListener('click', () => {
try {
closedShadowHost.shadowRoot.querySelector('.shadow-content').textContent = 'Attempted to update closed shadow root!';
} catch (e) {
alert('Cannot access shadow root content from outside!');
}
});
</script>
</body>Aquí el intento falla porque el shadow root está cerrado: closedShadowHost.shadowRoot devuelve null, por lo que null.querySelector(...) lanza un TypeError y se ejecuta el bloque catch. La referencia devuelta por attachShadow({ mode: 'closed' }) es la única forma de acceder a ese árbol, así que mantenla privada dentro de tu componente.
Un error común es creer que closed hace que un componente sea verdaderamente seguro, pero no es así. Solo desanima el acceso externo casual; el código que conserve la referencia original al root (o que parchee attachShadow) aún puede acceder a él. Usa open a menos que tengas una razón concreta para ocultar los internos, porque open facilita mucho la depuración y las pruebas.
| Aspecto | mode: 'open' | mode: 'closed' |
|---|---|---|
host.shadowRoot | Devuelve el shadow root | Devuelve null |
| Acceso externo | Permitido mediante host.shadowRoot | Solo a través de la referencia guardada |
| Uso típico | La mayoría de componentes, depuración sencilla | Ocultar internos de los scripts de la página |
| Inspección en DevTools | Completamente visible | Visible, pero más difícil de controlar con scripts |
Estilos dentro del Shadow DOM
Al implementar JavaScript Shadow DOM, asegúrate de aplicar una encapsulación adecuada para evitar conflictos de estilos o scripts no deseados.
Los estilos definidos dentro de un shadow root no afectan a los elementos externos, y viceversa. Esta encapsulación es beneficiosa para crear componentes reutilizables.
<head>
<style>
.styled-box {
color: red;
background-color: yellow;
padding: 10px;
border: 1px solid green;
}
</style>
</head>
<body>
<div class="styled-box">This is styled by the main document</div>
<div id="styled-host"></div>
<script>
const styledHost = document.getElementById('styled-host');
const shadowRoot = styledHost.attachShadow({ mode: 'open' });
shadowRoot.innerHTML = `
<style>
.styled-box {
color: white;
background-color: black;
padding: 10px;
border-radius: 5px;
}
</style>
<div class="styled-box">Styled by Shadow DOM</div>
`;
</script>
</body>En este ejemplo, hay dos elementos con el nombre de clase styled-box. El primer elemento recibe los estilos del CSS del documento principal, mientras que el segundo recibe los estilos del CSS del Shadow DOM. Los estilos definidos en el Shadow DOM no afectan a los elementos del documento principal, y los estilos del documento principal no afectan a los elementos del Shadow DOM. Esto demuestra cómo el Shadow DOM encapsula los estilos, garantizando que no haya conflictos entre los estilos del componente y los estilos globales.
Selectores Especiales para Shadow DOM
La encapsulación no significa aislamiento total. Tres selectores ofrecen puntos de control a través de la frontera:
:host— se usa dentro del shadow tree para aplicar estilos al propio elemento host.:host(.active)coincide solo cuando el host lleva esa clase.::slotted(selector)— se usa dentro del shadow tree para aplicar estilos a los nodos del Light DOM proyectados en un slot. Solo puede actuar sobre los elementos slotted de nivel superior, no sobre sus descendientes.::part(name)— se usa en el documento externo para aplicar estilos a un elemento interno que el componente expone explícitamente con un atributopart="name". Esta es la forma sancionada de permitir que los consumidores tematicen un componente sin acceder a sus internos.
<body>
<div id="theme-host">
<span>Projected from the light DOM</span>
</div>
<style>
/* Outer page can only reach parts the component exposes */
#theme-host::part(label) {
text-decoration: underline;
}
</style>
<script>
const host = document.getElementById('theme-host');
const root = host.attachShadow({ mode: 'open' });
root.innerHTML = `
<style>
:host { display: block; padding: 10px; border: 2px solid teal; }
.label { font-weight: bold; color: teal; }
::slotted(span) { color: crimson; }
</style>
<div class="label" part="label">Styled with :host and ::part</div>
<slot></slot>
`;
</script>
</body>La regla :host enmarca todo el componente, .label es interno y privado, ::slotted(span) colorea el texto del Light DOM proyectado, y ::part(label) permite que la página externa subraye la etiqueta a cuyo estilo se le dio permiso. Todo lo que no esté expuesto como un part permanece inaccesible desde el exterior.
Slots: Contenido del Light DOM en el Shadow DOM
Los slots permiten a los desarrolladores pasar contenido del Light DOM (DOM ordinario) al Shadow DOM, haciendo que el Shadow DOM sea más flexible y reutilizable.
<div id="slot-host">
<span slot="title">Shadow DOM Slot Example</span>
</div>
<script>
const slotHost = document.getElementById('slot-host');
const shadowRoot = slotHost.attachShadow({ mode: 'open' });
shadowRoot.innerHTML = `
<style>
.container {
border: 1px solid #ccc;
padding: 10px;
}
</style>
<div class="container">
<h1><slot name="title"></slot></h1>
<p>This is a Shadow DOM component with a slot for the title.</p>
</div>
`;
</script>En este ejemplo, el elemento <slot> se usa para pasar contenido del Light DOM al Shadow DOM. El atributo slot en el elemento span coincide con el atributo name del elemento slot en el Shadow DOM, lo que permite que el contenido del span se proyecte en el Shadow DOM.
Interacción de JavaScript con el Shadow DOM
Interactuar con el Shadow DOM mediante JavaScript requiere comprender los límites de encapsulación. La manipulación directa dentro del shadow root es sencilla, pero la interacción externa requiere un manejo cuidadoso.
Acceso a Elementos del Shadow DOM
Para acceder a los elementos dentro de un Shadow DOM, utiliza la propiedad shadowRoot.
<div id="interactive-host"></div>
<script>
const interactiveHost = document.getElementById('interactive-host');
const shadowRoot = interactiveHost.attachShadow({ mode: 'open' });
shadowRoot.innerHTML = `
<button id="shadow-btn">Click me</button>
`;
const shadowButton = shadowRoot.querySelector('#shadow-btn');
shadowButton.addEventListener('click', () => {
alert('Button inside Shadow DOM clicked!');
});
</script>En este ejemplo, accedemos al botón dentro del Shadow DOM usando querySelector sobre el shadow root. Como el shadow root es abierto, podemos adjuntar oyentes de eventos y manipular elementos directamente desde el documento principal.
Redirección de Eventos
Los eventos que burbujean fuera de un shadow tree son redirigidos: para los oyentes del documento externo, event.target apunta al shadow host, no al elemento interno que fue realmente pulsado. Esto mantiene la estructura interna en privado. Dentro del shadow tree, el objetivo real sigue estando disponible a través de event.composedPath()[0] o event.target.
<div id="event-host"></div>
<script>
const host = document.getElementById('event-host');
const root = host.attachShadow({ mode: 'open' });
root.innerHTML = '<button id="inner">Click me</button>';
// Listener in the OUTER document
document.addEventListener('click', (e) => {
console.log('Outer target:', e.target.id || e.target.tagName);
console.log('Real target:', e.composedPath()[0].id);
});
</script>Al hacer clic en el botón se registra Outer target: event-host (redirigido al host), pero Real target: inner proveniente de composedPath(). Ten en cuenta que los eventos personalizados solo cruzan la frontera del shadow cuando se crean con { bubbles: true, composed: true }.
Ejemplos Prácticos de Shadow DOM
Creación de un Web Component Reutilizable
Crear un web component reutilizable con Shadow DOM implica definir un elemento personalizado y adjuntarle un shadow root.
<body>
<custom-card title="Hello World"></custom-card>
<script>
class CustomCard extends HTMLElement {
constructor() {
super();
const shadowRoot = this.attachShadow({ mode: 'open' });
shadowRoot.innerHTML = `
<style>
.card {
padding: 10px;
border: 1px solid #ddd;
border-radius: 5px;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
}
.card-title {
font-size: 1.2em;
margin-bottom: 5px;
}
</style>
<div class="card">
<div class="card-title">${this.getAttribute('title')}</div>
<div class="card-content"><slot></slot></div>
</div>
`;
}
}
customElements.define('custom-card', CustomCard);
</script>
</body>En este ejemplo, se crea un elemento personalizado <custom-card> con un Shadow DOM. El Shadow DOM encapsula los estilos y la estructura del componente, haciéndolo reutilizable sin preocuparse por conflictos de estilos con el documento principal. Combinar Shadow DOM con elementos personalizados y el elemento <template> es la receta estándar para los Web Components en producción.
Integración con Frameworks
El Shadow DOM puede usarse de forma fluida con frameworks modernos de JavaScript como React, Angular y Vue.
Ejemplo con React
En React, puedes adjuntar un Shadow DOM a un elemento contenedor de la siguiente manera:
<body>
<div id="root"></div>
<!-- React and ReactDOM CDN links -->
<script src="https://unpkg.com/react@18/umd/react.development.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js" crossorigin></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script type="text/babel">
const { useRef, useLayoutEffect } = React;
const CustomCard = ({ title, content }) => {
const cardRef = useRef(null);
useLayoutEffect(() => {
if (cardRef.current) {
const shadowRoot = cardRef.current.attachShadow({ mode: 'open' });
shadowRoot.innerHTML = `
<style>
.card {
padding: 10px;
border: 1px solid #ddd;
border-radius: 5px;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
}
.card-title {
font-size: 1.2em;
margin-bottom: 5px;
}
</style>
<div class="card">
<div class="card-title">${title}</div>
<div class="card-content">${content}</div>
</div>
`;
}
}, [title, content]);
return <div ref={cardRef}></div>;
};
const App = () => (
<CustomCard title="Hello World" content="This is content inside the shadow DOM.">
</CustomCard>
);
const rootElement = document.getElementById('root');
const root = ReactDOM.createRoot(rootElement);
root.render(<App />);
</script>
</body>En este ejemplo, se crea un componente React CustomCard que adjunta un Shadow DOM a un div ordinario. El Shadow DOM garantiza que los estilos y la estructura del componente estén encapsulados, proporcionando una integración fluida con React.
Cuándo Usar el Shadow DOM
El Shadow DOM no es necesario para todos los componentes, así que sopesa los pros y contras:
- Úsalo cuando distribuyas un widget autocontenido y reutilizable, especialmente en páginas cuyo CSS global no controlas (incrustaciones, primitivas de sistemas de diseño, widgets de terceros).
- Evítalo cuando tu componente vive completamente dentro de una aplicación que ya delimita los estilos (CSS Modules, estilos con scope, BEM) y quieres que el tema global fluya libremente.
- Ten cuidado con estos errores comunes:
- Las hojas de estilo globales y las fuentes no se transmiten automáticamente en cascada; declara lo que necesites dentro del root, o pasa los valores con propiedades personalizadas de CSS (
--my-color), que sí atraviesan la frontera. - Los elementos asociados a formularios necesitan configuración adicional (la API
ElementInternals) para participar en un<form>externo. - El renderizado del lado del servidor de shadow trees requiere Declarative Shadow DOM (
<template shadowrootmode="open">).
- Las hojas de estilo globales y las fuentes no se transmiten automáticamente en cascada; declara lo que necesites dentro del root, o pasa los valores con propiedades personalizadas de CSS (
Regla general: prefiere mode: 'open' y expón puntos de tematización con ::part() y propiedades personalizadas de CSS. Usa closed solo cuando ocultar los internos sea un requisito real.
Conclusión
Dominar el Shadow DOM es fundamental para el desarrollo web moderno, ya que proporciona una potente encapsulación y reutilización. Al comprender e implementar los conceptos y ejemplos presentados, puedes crear componentes robustos y aislados que mejoran la mantenibilidad y escalabilidad de tus aplicaciones web.
Esta guía completa debe servir como una base sólida para explorar y utilizar el Shadow DOM en tus proyectos. Ya sea que estés creando widgets simples o aplicaciones complejas, el Shadow DOM ofrece la encapsulación y la flexibilidad necesarias para garantizar que tus componentes permanezcan aislados y manejables.