Time for another story of Storybook shenanigans.

I’m currently working on a greenfield (ish) design system. One of the stated objectives is to be as framework agnostic as possible. We have a huge number of potential users using a huge variety of tech stacks to develop their front-ends - from the common contenders of React, Angular and Vue, through SSR HTML from a CMS with a sprinkling of jQuery, plus those without any reactive framework whatsoever.

This is not a paritcularly new, nor unique problem. Many design systems have multiple implementations, such as Carbon Design System, available in two maintained and two community flavours.

However, development (and therefore documentation) of these implementations tends to be spread over multiple repositories and storybooks.

My reasons, and methods, for avoiding that split as far as development goes I may cover in a separate post, but in this I wanted to talk about how I’ve unified them all under a single storybook.

What’s the goal?

The goal is to have a single storybook with a selector in the toolbar that allows you to switch between implementations and browse the storybook under that context.

What’s the catch?

The catch is you have to pick one of Storybook’s renderers and fit everything around that. I chose React, because it’s our flagship implementation, and you already need to have it for Storybook’s own components.

This means writing bridges to mount, for instance, Vue components in React1. Depending on how that is done, getting the source to render correctly in docs may be tricky, and I’ve yet to fully explore it.

You’ll also need to write harnesses to render your components from a common set of args.

This is/should not be quite the burden it might sound:

  1. All your implementations should have very similar properties, modulo any idiomatic framework considerations. Not because they need to match each other, but because they should match the parameters of the upstream design.
  2. Any props that can’t be expressed as primitive storybook controls probably need mapping to primitives anyway - e.g. an icon prop that accepts an arbitrary node could be replaced with a bool or possibly a string[] of options.

What’s needed?

  1. Register a globalTypes in your Preview config:
    export default {
      globalTypes: {
        renderer: {
          name: "Renderer",
          defaultValue: "react",
          toolbar: [
            { title: "React", value: "react" },
            { title: "Elements", value: "elements" },
          ]
        }
      }
    }
    
  2. Write your harnesses:
    export type ButtonArgs = React.ComponentProps<typeof Button> & React.ComponentProps<'my-button'>;
       
    export const ElementsButtonHarness = ({appearance, ...props}: ButtonArgs): React.JSX.Element => {
      return <my-button appearance={appearance}><button {...props} /></my-button>;
    };
    
  3. Write a render function:
    export const multiRender = <Args,>(renderers: {
      react?: ArgsStoryFn<ReactRenderer, Args>;
      elements?: ArgsStoryFn<ReactRenderer, Args>;
    }): ArgsStoryFn<ReactRenderer, Args> => {
      return (props, context) => {
        const renderer = context.globals.renderer;
        const Component = context.component;
        switch (renderer) {
          case "react":
            return renderers?.react?.(props, context) || <Component {...props} />;
          default:
            return (renderers?.[renderer] ?? fallbackRenderer)(props, context);
        }
      }
    }
    
  4. Use it in a story:
    export default {
      component: Button,
      render: multiRenderer({ elements: ElementsButtonHarness }),
    } satisfies Meta<ButtonArgs>
    

I’ve left out a few details here relating to typing, but these are the basics that are serving us very well so far.

We’re only currently supporting implementations that are trivially renderable via React, but I don’t see any insurmountable problems in supporting more finickity frameworks - it took me twenty minutes to get 90% of the way there with Vue, for instance.

But wait, there’s more!

Yes, it’s a bonus section. You might be asking what the point of all those harnesses are; why not just do everything inline in the stories files?

Tests!

You can now parameterise your tests by render framework:

describe.each([
  ["react", renderHarness(Button)],
  ["elements", renderHarness(ElementsButtonHarness)]
])("Button (%s)", (_, renderFn) => {
  it("works", async () => {
    renderFn();
    await expect.element(page.getByRole("button")).toBeInTheDocument();
  });
});

Now you can be far more confident that all your implementations exhibit the same behaviour.

You may obviously still have need for testing individual implementation details, but one particular fun bit you can do with this is to take snapshots (DOM, a11y, or screenshots) of the different renderers, and ensure they are identical.


  1. This is perfectly doable. I remember having had knockout nested inside React inside Vue inside React before, usually at 3am in a cold sweat.