Using Storybook with Nunjucks components in Eleventy

When I’m working on an Eleventy site, I typically use Nunjucks macros to build components on the pre-rendered side of things. In particular, I’ve grown really fond of Trys Mudford’s encapsulated component pattern, which makes importing and calling macros a nice and tidy affair:

---
permalink: /index.html
---
{%- from "components/component.njk" import component -%}

<h1>{{ site.title }}</h1>

{{ component( "button", {
    label: "Hello from Eleventy",
    primary: true,
    size: "large"
}) }}

Macro-based components lend themselves to an idea I’ve been circling around for a little while: an internal development “library” of reference pages, built right into the project using Eleventy and skipped during production builds.

I envisioned using the library to develop components and utility classes separately from the site where they’d eventually be used, and it worked pretty well:

So far, at least, building components and utilities in isolation first and then using them elsewhere has helped ensure there aren't any contextual dependencies or assumptions that make things work one place but break in others.

Still, I found that building and maintaining the library’s infrastructure took almost as much time and effort as the site itself 🥴

Fortunately, there are some excellent tools purpose-built to address this exact need.

Storybook

Storybook is a browser-based tool for developing front-end components in isolation, testing various states and variations of those components, and combining them in contexts as they’re expected to exist in a final product. In short, it’s the library I’ve been dreaming of—and more.

The introduction page captures what I see as its core strength:

Storybook helps you build UI components in isolation from your app's business logic, data, and context. That makes it easy to develop hard-to-reach states. Save these UI states as stories to revisit during development, testing, or QA.

Music to my ears!

If you’re not familiar with Storybook, the UI looks like this:

Storybook interface showing a button component labeled 'Button with a custom label'

Each component has one or more “stories” that capture a specific state, like the Primary, Secondary, Large, and Small variations of the Button component above.

Moreover, a story can accept arguments that are used to define a component’s state via manipulable attributes—like a button label, or a two-state modifier, or a range of size classes, etc. These can be toggled and tweaked in real-time with Storybook’s UI, allowing a developer or tester to move a component into new states quickly and easily.

This maps so closely to how macro-based Nunjucks components are invoked that it seems like the two would be a natural fit! Storybook doesn’t support either Eleventy or Nunjucks directly, though, and for a while I’ve assumed that was a non-starter.

But after spending some time wrestling with webpack, I discovered that by combining Storybook’s built-in HTML framework with some additional configuration we can get everything playing together nicely.

If you’re a step-by-step learner, the following guide will walk you through the process of adding Storybook to your Eleventy project and configuring things to work with macro-based Nunjucks components. (If you prefer to poke through the source of a final product, I’ve put an example repo up on GitHub.)

Adding Storybook to your project

Before starting, it’s important to note this guide and the sample project use the following structure for Nunjucks components and their Storybook files:

src/
├─ _includes/
│  ├─ components/
│  │  ├─ button/
│  │  │  ├─ button.css
│  │  │  ├─ button.js
│  │  │  ├─ button.njk
│  │  │  └─ button.stories.js
│  │  ├─ header/

As in Storybook’s default examples, the component’s styles are defined using a standalone stylesheet that lives alongside the Nunjucks macro.

Webpack handles this automatically for Storybook, but your Eleventy project will need method for rounding up stylesheets spread over individual component folders and plopping them in the right places. (If you don’t currently have a solution for this, I’ve provided a couple of approaches at the end of this guide.)

If that sounds reasonable, let’s get crackin’!

Install dependencies

First, install @storybook/cli as a dev dependency in your Eleventy project:

npm install --save-dev @storybook/cli

Next, run the Storybook setup command at the root of your project and specify that we want to use the plain old HTML framework:

npx sb init --type html

Finally, install simple-nunjucks-loader@2, a Nunjucks loader for webpack, as a dev dependency:

npm install --save-dev simple-nunjucks-loader@2

The latest major version of simple-nunjucks-loader seems to be incompatible with webpack 4, the version Storybook currently uses, but downgrading to simple-nunjucks-loader@2 works great 🌟

Configuring Storybook

With dependencies installed, there’s one more detail to take care of before getting our Nunjucks component into Storybook.

Register the Nunjucks loader

We need to tell Storybook’s webpack instance to use the Nunjucks loader for any files that have a .njk file extension. (Otherwise, when it comes time to write stories, webpack won’t know what to do with our macro template files.)

Open .storybook/main.js and add the following as a top-level property of the module.exports object:

"webpackFinal": (config) => {
    config.module.rules.push({
        test: /\.njk$/,
        use: [
            {
                loader: 'simple-nunjucks-loader',
            }
        ]
    });

    // Return the altered config
    return config;
}

Tidy up default stories

Storybook setup creates a src/stories folder with some default examples. If you don’t plan to create stories outside individual components in Eleventy’s _includes folder or if they should live elsewhere, you can safely move stories anywhere in src or delete it altogether.

Creating a component story

Now that Storybook is installed and configured to support Nunjucks templates, let’s recreate an abridged version of the default Button example component and its stories.

Create the component styles

First, create a button folder inside src/_includes/components, then paste the following into a new file named button.css:

.storybook-button {
      font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
      font-weight: 700;
      border: 0;
      border-radius: 3em;
      cursor: pointer;
      display: inline-block;
      line-height: 1;
    }
    .storybook-button--primary {
      color: white;
      background-color: #1ea7fd;
    }
    .storybook-button--secondary {
      color: #333;
      background-color: transparent;
      box-shadow: rgba(0, 0, 0, 0.15) 0px 0px 0px 1px inset;
    }
    .storybook-button--small {
      font-size: 12px;
      padding: 10px 16px;
    }
    .storybook-button--medium {
      font-size: 14px;
      padding: 11px 20px;
    }
    .storybook-button--large {
      font-size: 16px;
      padding: 12px 24px;
    }

This CSS was taken directly from Storybook’s Button component example, without any permission whatsoever 😇

Create the Nunjucks macro

Next, paste the following into a new file button.njk:

{#
 # @prop {string} label
 # @prop {boolean} [primary=false]
 # @prop {string} [size="medium"]
 #}
{% macro render( props ) %}
{%- set mode = "primary" if props.primary else "secondary" -%}
<button class="storybook-button storybook-button--{{ props.size }} storybook-button--{{ mode }}">
    {{ props.label }}
</button>
{% endmacro %}

This gives us a perfectly functional Nunjucks macro that Eleventy could import, either directly:

{% import "components/button/button.njk" as button %}

{{ button.render({
    label: "Hello from Eleventy",
    primary: true,
    size: "large"
}) }}

or using the encapsulated component pattern:

{%- from "components/component.njk" import component -%}

{{ component( "button", {
    label: "Hello from Eleventy",
    primary: true,
    size: "large"
}) }}

There’s a medium-sized catch, however! 😬

When rendering a Nunjucks template, macros must be called explicitly; simply including or rendering button.njk in its current state would result in empty output.

This isn’t an issue in Eleventy — in fact, it’s one of the reasons Nunjucks macros work so well for building and injecting reusable components! Unless I’m mistaken, though, the simple-nunjucks-loader package we’re using to add Nunjucks support to webpack doesn’t provide a way to call macros 😅

You’ve read too far and for too long for me to throw up my hands now and say, “Sorry, Nunjucks and Storybook don’t work together actually!” and try to Homer-bush my way out of here.

So instead, let’s add a little block below the render() macro in button.njk:

{% if storybookArgs %}
    {{ render( storybookArgs ) }}
{% endif %}

This tells Nunjucks, “Listen. If you encounter a context variable named storybookArgs when you’re rendering button.njk, go ahead and call the render() macro automatically and pass that variable as its props object. Otherwise, just go about your business and don’t do anything special.”

By adding an automatic macro invocation that’s dependent on the presence of the storybookArgs context variable, our Nunjucks template can continue to be imported and rendered in Eleventy as usual without any change in behavior, while also giving us a handhold when it comes time to render the component in Storybook.

🎈 The name of this context variable can be anything, just make sure to set it to something that wouldn’t otherwise be defined in your project’s data cascade.

Create the component rendering script

Next up, create button.js alongside the other component files inside ./src/_includes/components/button, and paste the following:

import './button.css';
import renderButton from './button.njk'

/**
 * @param {Object} args
 * @param {string} args.label
 * @param {boolean} [args.primary]
 * @param {string} [args.size="medium"]
 */
export const createButton = (args = {}) =>
{
    args.size = args.size || "medium";

    // Setting a `storybookArgs` context variable causes the render() macro to
    // be called automatically 📕✨
    return renderButton({
        storybookArgs: args
    });
};

This file is the Nunjucks counterpart to the stories/Button.js example file generated during Storybook setup, and it does a few different things.

First, it tells webpack to import our component’s stylesheet. Nice.

It also imports a function we’re calling renderButton that’s used to render the contents of button.njk, made possible by simple-nunjucks-loader.

Finally, the script exports a function createButton for use by the stories script we’ll set up next. The function accepts Storybook arguments and passes them to the Nunjucks template for rendering. If you look closely, we’re setting a context object with a top-level property called storybookArgs!

Paired with the {% if storybookArgs %} block we tacked on in button.njk, it’s this property that kicks off automatic macro invocation and allows us to use the same Nunjucks template file in both Eleventy and Storybook

Create the stories script

Finally—at long last!—create button.stories.js and paste the following:

import {createButton} from './button';

export default {
    title: 'Nunjucks Button',

    // More on argTypes: https://storybook.js.org/docs/html/api/argtypes
    argTypes: {
        label: { control: 'text' },
        primary: { control: 'boolean' },
        size: {
          control: { type: 'select' },
          options: ['small', 'medium', 'large'],
        },
    },
};

const Template = ({ label, ...args }) => {
    return createButton({ label, ...args });
};

export const Primary = Template.bind({});
Primary.args = {
    primary: true,
    label: 'Button',
};

export const Secondary = Template.bind({});
Secondary.args = {
    label: 'Button',
};

export const Large = Template.bind({});
Large.args = {
    size: 'large',
    label: 'Button',
};

export const Small = Template.bind({});
Small.args = {
    size: 'small',
    label: 'Button',
};

Also taken rather unceremoniously from Storybook’s Button component example.

This script imports the createButton function from button.js, defines argument types for use in Storybook, and exports a handful of stories that capture different states for our Button component.

Launch Storybook

Here we go! Run the following command at the root of your Eleventy project:

npm run storybook

After some initial webpacking, Storybook launches with a big, beautiful blue button piped in from the same Nunjucks template that we use as a reusable component in our project:

Storybook interface showing a button component labeled 'Nunjucks Button'

Best of all, we still get the same reactivity that really makes Storybook shine: toggling the primary control switches between button styles, changing the label value updates the rendered component automatically, and so on.

So that’s it! Storybook and macro-based Nunjucks components built for Eleventy, together at last 📕🎈🐀🎆

If you get jammed up for some reason—or if you just find this useful, or come up with a cool variation or improvement—give me a holler! I’d love to hear from you.

As a reminder, an example project is up on GitHub for you to peruse. And if you want to peek at the Storybook we built here, a static copy is available to play with up on Netlify.

Appendix: Stylesheet Roundup

Here are a couple of different approaches you might consider for including component stylesheets in your site.

Inline CSS

If you’re using Inline Minified CSS—a great approach for a small site without a bunch of CSS—you can include your component styles by adding them below any existing stylesheets:

<!-- capture the CSS content as a Nunjucks variable -->
{% set css %}
    {% include "sample.css" %}
    {% include "components/button/button.css" %}
{% endset %}

<style>
  {{ css | safe }}
</style>

This requires manually adding a stylesheet each time a new component is added, which might get a little fiddly after a while.

Global styles data

A similar approach—which is potentially kind of weird, admittedly!—combines the simplicity of inline minified CSS with the power of Eleventy data.

First, install both clean-css and glob (or any other CSS minifying and globbing packages you might prefer):

npm install --save clean-css glob

Next, create styles.js in your _data folder and add the following:

const CleanCSS = require( "clean-css" );
const glob = require( "glob" );

/*
 * A loose interpretation of CUBE CSS, which is awesome
 * https://cube.fyi/
 */
const sources = [
    {
        name: "global",
        path: "./src/styles/global.css",
    },
    {
        name: "composition",
        path: "./src/styles/composition.css",
    },
    {
        name: "utilities",
        path: "./src/styles/utilities.css",
    },
    {
        name: "blocks",
        path: glob.sync( "./src/_includes/components/**/*.css" ),
    },
];

const cleanCss = new CleanCSS(
    {} // See https://www.npmjs.com/package/clean-css#constructor-options
);

const styles = {};

sources.forEach( source =>
{
    let sourcePath = Array.isArray( source.path ) ? source.path : [source.path];
    let result = cleanCss.minify( sourcePath )

    if( result.warnings.length > 0 )
    {
        console.log( result.warnings );
    }

    if( result.errors.length > 0 )
    {
        throw new Error( result.errors.join( "\n" ) );
    }

    styles[source.name] = result.styles;
});

module.exports = styles;

The sources array defines stylesheet “scopes” in the order they should be imported. The example above loosely follows the CUBE CSS, which I really love, but you could use a simpler source set depending on your setup:

const sources = [
    {
        name: "global",
        path: "./src/styles/sample.css",
    },
    {
        name: "blocks",
        path: glob.sync( "./src/_includes/components/**/*.css" ),
    },
];

In either case, using a glob pattern automatically adds any stylesheets found in src/_includes/components, eliminating the need to manually manage stylesheets any time a new component is created or removed.

Finally, the script passes each path or glob pattern to CleanCSS for minification, then adds the resulting styles by name as top-level properties of a global data object called styles:

{
    global: 'body{background-color:var(--background-color)}a{color:currentcolor} [...]',
    composition: '.container{max-width:var(--container-size)}.stack-sm>*+*{margin-top:var(--stack-sm-size)} [...]',
    utilities: '.center{margin-left:auto;margin-right:auto}',
    blocks: `.page{color:var(--tint-900);.storybook-button--large{font-size:16px;padding:12px 24px} [...]`
}

Like the previous inline CSS approach, we can inject styles using the global data object styles:

<style>
    {{ styles.global | safe }}
    {{ styles.composition | safe }}
    {{ styles.utilities | safe }}
    {{ styles.blocks | safe }}
</style>

Separating by “scope” on the styles object lets us plop specific styles where they should be included. For example, maybe we want to include global, composition, and utilities style in our inline CSS:

<style>
    {{ styles.global | safe }}
    {{ styles.composition | safe }}
    {{ styles.utilities | safe }}
</style>

and build an external stylesheet for components and other blocks:

---
permalink: main.css
---
{{ styles.blocks | safe }}

Have a different approach to managing component styles in your Eleventy project? I’m always fussing with this part, and I’d love to hear what people are cooking up!