If you are familiar with Web Component, then you must know about Shadow DOM. Shadow DOM is used to create a separate DOM tree that is isolated from the external DOM. It is commonly used in micro-frontends and allows you to define any styles internally without affecting the external styles. However, this feature also means that Shadow DOM does not inherit any external styles. If you use TailwindCSS or other component libraries with styles, they will not be applied within the Shadow DOM.
Example#
Let's start by creating a simple single-page application using TailwindCSS.
// Create a shadow DOM tree and define a custom element
class MyCustomElement extends HTMLElement {
constructor() {
super()
// Attach a shadow DOM to the custom element
const shadow = this.attachShadow({ mode: 'open' })
// Create some content for the shadow DOM
const wrapper = document.createElement('div')
wrapper.setAttribute('class', 'text-2xl bg-primary text-white p-4')
wrapper.textContent = 'I am in the Shadow DOM tree.'
// Append the content to the shadow DOM
shadow.append(wrapper)
}
}
// Define the custom element
customElements.define('my-custom-element', MyCustomElement)
export const Component = () => {
return (
<>
<p className="text-2xl bg-primary text-white p-4">
I'm in the Root DOM tree.
</p>
<my-custom-element />
</>
)
}
The result of the above code is as follows:
The element above is located in the Host (Root) DOM, and the styles from TailwindCSS are correctly applied. However, the element within the ShadowRoot cannot apply styles and still retains the default browser styles.
Solution#
We know that bundlers inject CSS styles into the document.head
. So, all we need to do is extract these tags and inject them into the ShadowRoot.
Here's how you can achieve this using React, but the same principle applies to other frameworks as well.
You can use react-shadow to use Shadow DOM in React and improve the developer experience (DX).
npm i react-shadow
The code above can be modified as follows:
import root from 'react-shadow'
export const Component = () => {
return (
<>
<p className="text-2xl bg-primary text-white p-4">
I'm in the Root DOM tree.
</p>
<root.div>
<p className="text-2xl bg-primary text-white p-4">
I'm in the Shadow DOM tree.
</p>
</root.div>
</>
)
}
Now, the styles are still not applied. Let's inject the host styles.
import type { ReactNode } from 'react'
import { createElement, useState } from 'react'
import root from 'react-shadow'
const cloneStylesElement = () => {
const $styles = document.head.querySelectorAll('style').values()
const reactNodes = [] as ReactNode[]
let i = 0
for (const style of $styles) {
const key = `style-${i++}`
reactNodes.push(
createElement('style', {
key,
dangerouslySetInnerHTML: { __html: style.innerHTML },
}),
)
}
document.head.querySelectorAll('link[rel=stylesheet]').forEach((link) => {
const key = `link-${i++}`
reactNodes.push(
createElement('link', {
key,
rel: 'stylesheet',
href: link.getAttribute('href'),
crossOrigin: link.getAttribute('crossorigin'),
}),
)
})
return reactNodes
}
export const Component = () => {
const [stylesElements] = useState<ReactNode[]>(cloneStylesElement)
return (
<>
<p className="text-2xl bg-primary text-white p-4">
I'm in the Root DOM tree.
</p>
<root.div>
<head>{stylesElements}</head>
<p className="text-2xl bg-primary text-white p-4">
I'm in the Shadow DOM tree.
</p>
</root.div>
</>
)
}
Now, the styles are successfully injected. You can see that the ShadowDOM inherits the styles from the host.
Updating Host Styles Responsively#
With the current approach, if the host styles change, the styles in the ShadowDOM will not be updated.
For example, if we add a button that adds a new style when clicked:
<button
className="btn btn-primary mt-12"
onClick={() => {
const $style = document.createElement('style')
$style.innerHTML = `p { color: red !important; }`
document.head.append($style)
}}
>
Update Host Styles
</button>
You can see that the ShadowDOM does not update the styles.
We can use MutationObserver to observe changes in the <head />
and update the styles.
export const Component = () => {
useLayoutEffect(() => {
const mutationObserver = new MutationObserver(() => {
setStylesElements(cloneStylesElement())
})
mutationObserver.observe(document.head, {
childList: true,
subtree: true,
})
return () => {
mutationObserver.disconnect()
}
}, [])
// ..
}
The result is as follows:
Problem solved.
Conclusion#
Now, you might wonder why you should use ShadowDOM if you can simply inject styles into the global scope without affecting the host styles. The reason is that with ShadowDOM, you can inject any styles without polluting the global scope.
This solution is simple and can be applied in any framework or even in native JavaScript. It is a native solution that does not rely on any specific framework.
I just want to say that you should not let the various tools and plugins in modern front-end development limit your thinking. Sometimes, the solution to a problem is as simple as performing a basic DOM operation. So, don't forget the basics.