Qué son los CSS Shadow Parts, cómo usar el selector ::part() para conseguir aplicar estilos CSS globales a los custom elements con Shadow DOM.
CSS parts es una nueva tecnología que nos aporta el lenguaje CSS y que viene a solucionar una de las demandas más importantes de los desarrolladores de Web Components de los últimos años.
En este artículo te vamos a explicar cómo trabajar con CSS Part en nuestros custom elements y cómo escribir estilos globales CSS para que penetren en el shadow DOM.
El problema del CSS y el Shadow DOM
Una de las ventajas que nos aporta el desarrollo de Web Components es la encapsulación de los componentes en lo que se conoce como Shadow DOM. Este estándar Javascript permite que los componentes que desarrollamos estén aislados de otras partes de la página y que no sufran interferencias indeseadas.
Realmente puede ser un poco impreciso decir que el shadow DOM deje completamente asislado al componente. Existen algunos estilos que, naturalmente, se aplican a todos los componentes, como por ejemplo el font-family o el color. Si estos estilos no están redefinidos dentro del propio componente, se usarán los definidos en el CSS global. Pero este es el comportamiento habitual del HTML y el CSS, por lo que no debería de extrañarnos.
Esto soluciona diversos problemas habituales que existían anteriormente con otros sistemas como los plugins de jQuery, que cuando tenías varios en la página podrían romperse por interacciones entre ellos, o por el CSS que habíamos definido en la página a nivel global.
Con Shadow DOM los estilos CSS globales, definidos para toda la página, no pueden causar problemas en los componentes, lo que facilita mucho su reutilización. Sin embargo esto también es un arma de doble filo que ha causado conflictos de opiniones entre la comunidad de desarrolladores, especialmente aquellos que están acostumbrados a librerías sin encapsulación, como React, donde sí que se pueden usar los estilos globales dentro de los componentes.
Muchos desarrolladores acostumbrados también a trabajar con frameworks CSS como Bootstrap o Tailwind veían que al usar Web Components no podían reutilizar todas las clases del framework, lo que les resultaba en una experiencia inesperada y frustrante.
Obviamente, hay maneras de conseguir que los CSS globales consigan penetrar en los componentes, como las CSS Custom Properties (variables CSS), sin embargo hasta ahora se quedaban cortas o eran ineficaces.
- Por ejemplo, algunos desarrolladores decidían no usar Shadow DOM, lo que es perfectamente posible, pero con ello perdemos muchas de las ventajas del componente y se hace mucho más difícil trabajar con slots.
- Otros desarrolladores deciden usar @import en las declaraciones de los componentes, trayendo consigo una declaración de CSS global a cada custom element, pero eso es totalmente desaconsejado porque crea cantidades inmensas de código CSS que en realidad se está duplicando y mulplicando por la página, para cada vez que se usa el componente. Por supuesto, esta técnica influiría negativamente en el rendimiento de las aplicaciones.
CSS Parts mejora la aplicación de estilos en componentes con Shadow DOM
El Shadow DOM ha venido para quedarse y sus ventajas superan mucho a sus inconvenientes. Los avances en el estándar CSS y Web Components están dirigidos a permitir que la encapsulación de los Custom Elements sea un problema menor para los desarrolladores.
Aquí es donde CSS Part aparece, con el objetivo de mejorar sensiblemente las posibilidades del CSS global en componentes encapsulados con Shadow DOM. La idea es simplemente proporcionar un mecanismo para que los desarrolladores puedan crear CSS en los estilos globales y que este CSS pueda aplicarse en los componentes.
De este modo, podemos aplicar CSS desde fuera de los componentes con estilos tan complejos como sea necesario, consiguiendo que aumente el grado de personalización de los componentes, sin tener que modificar el código de los propios componentes.
Conociendo el selector CSS ::part()
::part es un pseudo-elemento de CSS que permite aplicar estilos a otros componentes usados en la página, cuyos elementos pertenecen a un Shadow DOM.
Para que funcione deben entrar en juego dos requisitos:
- En los elementos del Shadow DOM, o sea, en el marcado HTML del template, debemos usar el atributo "part" indicando el nombre de esta parte del componente. Este atributo "part" se puede colocar en cualquier etiqueta y contendrá el valor del nombre de esta CSS part.
- En el CSS global, usaremos el pseudo-elemento ::part, indicando la parte del componente a la que nos estamos refiriendo para aplicar los estilos.
Ahora vamos a ver algunos ejemplos para que quede todo perfectamente claro.
Usando el atributo part en el template de nuestro custom element
Primero vamos a ver cómo se usa el atributo part, que debemos colocar en las etiquetas HTML sobre las que queremos que desde fuera se les puedan aplicar estilos CSS.
<button part="un_boton">Esto es un botón estilizable desde fuera</button>
Como puedes ver, definir un CSS part es tan sencillo como aplicar un simple atributo HTML. Cada sección del componente que quieras que se pueda estilizar desde fuera necesita que se le asigne este atributo, dando un nombre a esa part, en este caso sería "un_botton".
Aplicando estilos con el pseudo-elemento ::part
Ahora vamos a ver cómo se le puede aplicar estilos a ese botón. Vamos a suponer que ese botón se haya usado en un custom element llamado "mi-elemento". Entonces podrías usar este CSS para aplicar estilos al botón que habría dentro del componente <mi-elemento>.
mi-elemento::part(un_boton) {
border: 1px solid orange;
font-size: 2rem;
}
También podríamos definir el "part" sin indicar el nombre del componente, para que afecte a todos los CSS parts que haya con este mismo nombre.
::part(un_boton) {
border: 1px solid orange;
font-size: 2rem;
}
De este modo, si hay varios componentes en la página que tienen botones con part="un_boton", todos se verían afectados por las reglas de estilo anteriores. La ventaja en este caso es que unas mismas reglas de CSS se pueden además especificar en un único lugar, lo que reduce la cantidad de código que debe ser escrito e interpretado por el navegador.
Es interesante mencionar que ::part() tiene más especificidad que los propios estilos definidos en el componente. Por ello, si los estilos globales definidos con ::part() colisionan con estilos definidos en el propio componente, ganarán los ::part() globales. Dicho de otro modo, si colocas un part en un componente lo estás exponiendo a cualquier posible manipulación de su estilo, incluso aunque tú hayas definido el CSS de una manera particular en el custom element. Como alternativa siempre podríamos usar !important, para conseguir vencer en los estilos definidos globalmente en un CSS Part, si es que fuera necesario para un componente en particular.
Estos selectores pueden complicarse un poco más usando otras pseudo-clases como :hover o :focus.
::part(un_boton):hover {
border: 2px solid red;
background-color: #666;
color: #ffc;
}
Compatibilidad de CSS Parts con los navegadores actuales
Esta característica de las CSS y Web Components la podemos usar con total tranquilidad, dado que está actualmente disponible en todos los navegadores actuales.
Por supuesto, Internet Explorer no la admite, pero ya sabemos que este navegador tiene una cuota de mercado residual, y ahora más desde que la propia Microsoft ha dejado de darle soporte.
Así pues gracias a ::part es posible ahora que desarrolladores reacios a usar Web Components se sientan más atraídos por este estándar Javascript.
Bonus: Cómo podrías usar clases Tailwind en componentes con Shadow DOM
Como hemos dicho, muchas de las reticencias al uso de Web Components provenían de la incapacidad de usar frameworks de CSS global, como Tailwind o Bootstrap. Gracias al selector ::part() ahora esto tendría una manera de solucionarse más fácilmente.
En el caso de Tailwind la solución más sencilla sería aplicar @apply para definir los estilos globales de un CSS part, utilizando en ella todas las clases de Tailwind que quieras que penetren en el shadow DOM.
::part('navegador') {
@apply p-4 m-2 bg-slate-700 text-white;
}
De esta manera, todos los componentes donde le pongamos el atributo part="navegador" a uno de sus elementos, tendrán los estilos Tailwind que se están definiendo con el @apply. Es verdad que seguiríamos sin poder usar directamente las clases de Tailwind en el template del componente, pero sería perfectamente viable usarlas a través del estilo global en las parts donde las queramos aplicar.
Un ejemplo práctico real de Shadow Parts
Ahora voy a relatar un caso en el que he usado shadow parts últimamente y que me permite estilizar contenido que declaro yo en un componente pero que no se va a renderizar en ese componente, sino en otro que tiene su shadow dom.
El contexto: un data-grid con celdas personalizadas
Al integrar <dile-data-grid> (un componente basado en Lit) en un proyecto Laravel + Inertia, quisimos mostrar los accesos de un usuario a una plataforma con columnas personalizadas: fecha con icono, duración y un badge de estado. La API del grid permite justo eso mediante una función render por columna:
{
field: "status",
header: "Estado",
render: (row) => html`
<span class="session-status">${this.statusLabel(row.status)}</span>
`,
}
Y en el componente que usa el grid, definimos los estilos de siempre:
static styles = css`
.session-status {
background: #e0e0e0;
border-radius: 1rem;
padding: 0.15rem 0.65rem;
}
`;
Resultado: el badge se renderizaba, pero sin ningún estilo. El fondo gris, el padding, el border-radius... nada llegaba. ¿Por qué?
El problema: dos Shadow DOM, dos mundos distintos
La clave está en entender dónde acaba físicamente el HTML que genera esa función render, no dónde está escrito el código que lo genera.
Cuando escribimos render: (row) => html\...`dentro de nuestro componenteEitSubsidisedUserSessionTimes, es fácil asumir que ese <span>"pertenece" a nuestro componente, porque ahí está el código fuente. Pero no es así: esa función la *invoca*dile-data-griddentro de su propio métodorender()`, y el resultado se inserta como parte de su propio shadow root:
// Dentro de DileDataGrid (no es nuestro código)
_renderCell(row, rowIndex, column) {
const content = column.render(row, rowIndex, column); // ← ejecuta NUESTRA función
return html`<td>${content}</td>`; // ← pero el nodo vive en SU shadow root
}
El árbol de DOM final queda así:
<eit-subsidised-user-session-times>
#shadow-root
<dile-data-grid>
#shadow-root ← ¡otro shadow root, distinto del anterior!
<table>
<td>
<span class="session-status">...</span> ← el nodo real vive AQUÍ
</td>
</table>
El Shadow DOM existe precisamente para encapsular estilos: las reglas CSS declaradas en static styles de un componente sólo aplican a los nodos que viven dentro de ese mismo shadow root. Nuestro .session-status está en el shadow root de EitSubsidisedUserSessionTimes, pero el <span> real vive dos niveles más adentro, en el shadow root de dile-data-grid. La barrera de encapsulación bloquea el paso, exactamente como está diseñado que funcione.
Este es un caso interesante porque no es un bug: es el Shadow DOM haciendo su trabajo. El problema es que la API del grid (una función render que devuelve una plantilla) hace muy fácil olvidar en qué "territorio" acaba insertándose ese contenido.
💡 Dato curioso: si en el mismo componente usábamos una custom property CSS (
--eit-icon-color) para colorear un icono, esa sí llegaba correctamente. Las custom properties heredan a través de los límites del Shadow DOM (es parte de la especificación), mientras que las reglas normales de clase no. Esa asimetría es justo la pista que delata el problema.
Alternativas descartadas
Antes de resolverlo, vale la pena mencionar qué NO hicimos y por qué:
- Estilos inline (
style="..."): funcionan porque van pegados al elemento, no dependen de una hoja de estilos externa. Es una solución válida, pero mezcla contenido y presentación, y no admite pseudo-clases (:hover) ni media queries. - Meter un
<style>dentro de la propia plantilla delrender: funciona (el<style>cae dentro del mismo shadow root que el<span>), pero se duplica en cada celda y acopla los estilos al código de la función renderer en lugar de vivir junto al resto de estilos del componente.
Ninguna de las dos nos permitía seguir teniendo los estilos centralizados en el static styles del componente, que es donde conceptualmente deberían vivir.
La solución: CSS Shadow Parts
Existe un mecanismo pensado exactamente para esto: CSS Shadow Parts (part / ::part()), parte del estándar de Web Components desde 2019 y soportado en todos los navegadores modernos.
La idea es simple: un componente puede "exponer" nodos concretos de su interior marcándolos con el atributo part, y cualquier consumidor externo puede seleccionarlos con el pseudo-elemento ::part() atravesando el límite del shadow root, sin necesidad de que el componente interno sepa nada de los estilos que le van a aplicar.
Aplicado a nuestro caso:
1. Exponemos el nodo con part en lugar de (o además de) class:
render: (row) => html`
<span part="session-status">${this.statusLabel(row.status)}</span>
`,
2. Lo seleccionamos desde fuera anteponiendo el nombre del elemento que contiene el shadow root:
static styles = css`
dile-data-grid::part(session-status) {
background: #e0e0e0;
color: #333;
border-radius: 1rem;
padding: 0.15rem 0.65rem;
font-size: 0.8rem;
white-space: nowrap;
}
`;
La sintaxis dile-data-grid::part(session-status) se lee como: "dentro del shadow root de dile-data-grid, dame el elemento marcado con part="session-status"". Y esto funciona sin importar que el <span> lo haya creado dinámicamente una función render que nosotros le pasamos al grid como configuración — lo único que importa es dónde vive el nodo en el árbol final, no quién escribió el código que lo generó.
Por qué esta es la solución "correcta" aquí
Lo interesante de este caso es que quien controla el atributo part es nuestro propio código (la función render que le pasamos al grid), no la librería del grid. Es decir: no hicimos ningún cambio en dile-data-grid, ni necesitamos que su API añadiera nada nuevo. El grid simplemente ejecuta la plantilla que le damos y la coloca en su shadow root; el part viaja con esa plantilla porque es un atributo HTML normal, no una regla de estilo con ámbito.
Esto la distingue de otras librerías (como Vaadin Grid) que resuelven el mismo problema con un enfoque distinto: usan <slot> para que el contenido de cada celda siga viviendo en el light DOM del consumidor. En nuestro caso, como el grid usa plantillas embebidas directamente en su propio render(), ::part() era la vía más limpia sin tocar la librería.
Resumen para recordar
- Las Reglas CSS normales (
.clase { ... }) No cruzan shadow roots - Custom properties (
--variable) Heredan a través de shadow roots - Estilos inline (
style="...") Viajan con el elemento, sin depender de dónde esté ::part()+ atributopartCruza un nivel de shadow root, de forma explícita y controlada
La regla mental útil: cuando un componente de terceros te deja "inyectar" una plantilla mediante una función (render, cellRenderer, template, etc.), esa plantilla casi siempre termina viviendo en el shadow root del componente, no en el tuyo. Si necesitas estilizarla desde fuera con reglas normales, márcala con part y estilízala con ::part() desde el componente contenedor.
Miguel Angel Alvarez
Fundador de DesarrolloWeb.com y la plataforma de formación online EscuelaIT. Com...