Design Systems Hot Takes

Storybook iframe tango

5 min read

Storybook is an essential part of design system development. It is considered an industry standard for creating components in isolation before they appear in production experiences. I’ve been working with the tool for nearly 8 years now and the team has really done a great job supporting the needs of our industry.

That said, we got a problem.

Leaky styles

I lean heavily into Storybook as a documentation platform. The product also went in that direction when introducing the ability to write stories as MDX. They’ve since walked back that approach in more recent iterations but, it is clear there is still a desire for writing documentation in the Storybook ecosystem.

When stories appear within documentation pages, the styles used to customize the documentation have a tendency to leak into the stories. As an example, if you are relying on the default body color to cascade down in your component, the styles of the documentation will also loosely target some of these things. In cases where your stories are shown in a different mode from the storybook, this can have an unintended presentation.

There’s a setting in Storybook that you can use which will create a barrier between the documentation styles and the stories.

export const preview: Preview = {
  parameters: {
    docs: {
      story: { inline: false }
    }
  }
}

That configuration solves a problem, but introduces another one.

Out of the frying pan

The above setting renders each story in an <iframe/> which is great for encapsulation, but awful for presentation. Specifically, the element does not know how big its content will be. Usually, this is a quick fix since the content of the frame is also the same domain as the page. We could just query inside the contentWindow for the body.scrollHeight and apply that amount to the <iframe/>; problem solved!

To do that, we first need to grab a reference to all the <iframe/> elements. Normally, this is document.getElementsByTagName('iframe') except Storybook renders on the client-side. That is, these <iframe/> elements don’t appear until later in the application lifecycle. So we have to wait until the page has these elements. Unfortunately, DOMContentLoaded doesn’t work because that fires before the hydration, same goes for the defer keyword on the <script/> tag. So how are we supposed to get these <iframe/> elements?

Enter my old friend, the Node Insertion hack. I first found this approach when working with registering web components. In fact, I speak about it within my very first post in the blog. We can use the same approach to wait for <iframe/> elements to appear and grab their reference. First, add the following CSS to the manager-head.html file:

iframe {
    visibility: hidden;
    animation: appear 0s linear forwards;
}

@keyframes appear {
    to { visibility: visible }
}

Next, add a <script/> tag in the same file with the following:


window.addEventListener('animationend', onAnimationEnd);

function onAnimationEnd(ev) {
    if (ev.animationName === 'appear') {
        handleIframe(ev.target);
    }
}

function handleIframe($iframe) {
    console.log($iframe);
}

This is not the element you are looking for

When you run storybook, you should get a log in the console that identifies an <iframe/> element. When you dig a bit deeper, you’ll recognize that the element it returned is the preview <iframe/>, not the one used for stories on a docs page. To get those <iframe/> elements, you’ll need to listen inside the preview also! Luckily, we can do this with a small change; update the preview-head.html to include the same CSS that we added to the manager-head.html. Let’s also update our handleIframe() function:

function handleIframe($iframe) {
    if ($iframe.id === 'storybook-preview-iframe') {
        $iframe.addEventListener('load', onLoad);
        onLoad.call($iframe);
    } else {
        adjustHeight($iframe);
    }
}

function onLoad() {
    this.contentWindow.addEventListener('animationend', onAnimationEnd);
}

function adjustHeight($iframe) {
    console.log($iframe);
}

Now, after you restart Storybook, the console should identify each individual Story <iframe/>. The next part is what we’ve been waiting to do. We want to get the height of the internal <body/> element and make the <iframe/> respect the size of the content. Storybook applies the height to the parent of the <iframe/> so we’ll need to get that in the process.

function adjustHeight($iframe) {
    const $body = $iframe.contentWindow.document.body;
    const $parent = $iframe.parentElement;
    const currentHeight = $parent.style.height;

    if (currentHeight === '400px') {
        $parent.style.height = $body.scrollHeight + 'px';
    }
}

In this function, we’re checking if the <iframe/> is the default set by Storybook (400px). If it is, we update the height based on the contents of the <body/>. This allows you to set the iframeHeight as a parameter is specific stories that should be a fixed height. This is helpful for stories that have content shifts, such as loading images.

Shadow DOM when?

At this point, I’m pretty sure that all of this could be avoided if the stories were rendered within a Shadow DOM. Maybe once I get truly bored I could start looking to see how I might do that. But in the meantime, check out the new version of the DAMATO Design System where I expect to publish real working examples of my approaches. It’s already been a big help in my regular discourse. More to come there over time.

Clarity 2024, Backstage