By default, stylesheets are bound to their document and don't leak into iframe elements (or vice versa). It does make sense for security and aesthetic reasons. A website would not want to be displayed with distorted styles when embedded on someone else's page.
So why would you do that?
I recently worked on a website that does not provide any form of hot-reload. Instead of building the assets manually, I decided to write the stylesheets in isolation.
So, I set up a storybook that would build the styles in real time and apply them to the stories. Each story would rely on an HTML file, whose content is copied from the website. Albeit simple, this approach was the first improvement in the developer experience.
That worked for a while until I had to apply styles on JavaScript-constructed elements (e.g. a Slider). I identified two solutions to this problem:
either make the JavaScript work in isolation
or don't work in isolation anymore
I chose the latter and started a new story with a single element: an iframe targeting the website running locally.
Connecting storybook to the iframe
To inject the styles built by storybook in the iframe, I needed to send data from the parent document to the document in the iframe. This can be achieved using the postMessage API.
Sending the message
On the "parent" side, sending data to the iframe is quite straightforward:
const iframe = document.getElementById("my-iframe");
iframe.contentWindow.postMessage(message, "*");
message
The message
argument can basically take any serializable data. Our message will be a looong string containing our CSS.
"*"
This second argument aims to specify which domain can receive the message. The wildcard ("*"
) means anyone can receive the message.
Catching the message
If we want more than a message in a bottle, we have to set up some code on the iframe side. Receiving the message implies listening to the "message"
event:
function receiveMessage(event) { ... }
window.addEventListener("message", receiveMessage, false);
The catch event contains:
data
: the message.
origin
: which domain the message was sent from.
source
: object referencing the sender window
and allowing two-way communication.
You need to find a way to add this script to the target website.
Don't forget to remove it before deploying it into production!
Adding the style to the document
We then use the received data as the innerText
of a freshly created <style>
element:
const styleTag = document.createElement("style");
document.head.appendChild(styleTag);
function receiveMessage(event) {
styleTag.innerText = event.data;
}
window.addEventListener("message", receiveMessage, false);
Hot-reload
Now, the target website is able to receive and inject the message in a <style>
tag.
But how do we get its content in the first place?
As I said in the introduction, the goal is to inject the styles into the iframe every time they change. I was able to achieve that using the MutationObserver API which can detect when storybook reloads the styles. I ended up writing the following decorator:
(story) => {
// Get all style tags in storybook's scope
const styles = document.getElementsByTagName("style");
// In my case, I only need the last style tag
const stylesToInject = styles[styles.length - 1];
// This callback is called when changes are observed
const callback = () => {
const iframe = document.getElementById("your-iframe");
iframe.contentWindow.postMessage(stylesToInject.innerText, "*");
}
// Create an observer instance linked to the callback function
const observer = new MutationObserver(callback);
// Observe style change
const config = { attributes: true, childList: true, subtree: true };
observer.observe(stylesToInject, config);
// Render iframe
return story();
};
Conclusion
Of course, those few lines are not optimized. The performance could be improved by sending only the difference. But, as perfectible as it may be, this trick did meaningfully improve the developer experience on my project.
Just keep in mind:
You need to be able to build your style in the storybook context
You may have to disable some CSP locks in order to display the website in the iframe.