Creating a theme switcher addon for storybook

At NHI we recently started to create a UI component library to get a consistent UI across all our apps. We use storybook for overview/showcase of the components, but we need theming.

We solve theming at NHI with HTML data attributes and with CSS variables. With this method we are confident that the feel will be consistent across themes and we can scope theme to a subsection of the html tree.

It's the same approach to theming I use on this website.

Showcase of theming

So I need a way to easily switch between themes in storybook.

You can look at the finished code at github or download the package from npm

Getting started

The things I want to achieve is:

  1. Add data-theme to the story container
  2. Create a button to switch theme
  3. Add themes via config
  4. make data-* tag to be customizable

In the storybook documentation they recommends using their addon-kit. I love easy so I will do that.

This includes both tools for development and for releasing the package to NPM.

Add data-theme

First item on the list is to add data-theme to the container. Since the storybook components from the stories are injected with an iframe, I need to add data-theme to the

html
element inside the iframe:
<html data-theme=default>
.

Decorator

The first thing I need is a decorator. This is a way to add extra functionality to a story and I will use

decorator
to get access to the html tag inside the iframe.

// withTheme.ts
import type { DecoratorFunction } from "@storybook/addons"
import { useEffect } from "@storybook/addons"

export const withTheme: DecoratorFunction = (StoryFn) => {
     useEffect(() => {
          const querySelector = global.document.querySelector("html")
          if (querySelector) {
               querySelector.setAttribute("data-theme", "kent")
          }
     }, [])

     return StoryFn()
}
// preset/preview.ts
import { withTheme } from "../withTheme"

export const decorators = [withTheme]

I can now see the data-theme is getting attached to the html element inside the iframe. Showing that data-tag works

Perfect.

Button to switch theme

Next on the list is to add a button to switch theme. For this I need a button, dropdown and to handle click events.

Button

The button is just an Icon with a paintbrush.

// ThemeSelector.tsx
import React from "react"
import { Icons, IconButton } from "@storybook/components"

export const ThemeSelector = () => {
     return (
          <IconButton key="theme" title="Change theme">
               <Icons icon="paintbrush" />
          </IconButton>
     )
}

I want the button to be shown in the tool menu at the top, so I have to register the addon as a tool and render the new button.

// preset/manager.ts
import { addons, types } from "@storybook/addons"
import { ADDON_ID, THEME_ID } from "../constants"
import { ThemeSelector } from "../ThemeSelector"

// Register the addon
addons.register(ADDON_ID, () => {
     // Register theme
     addons.add(THEME_ID, {
          type: types.TOOL,
          title: "Theme Selector",
          match: ({ viewMode }) =>
               !!(viewMode && viewMode.match(/^(story|docs)$/)),
          render: ThemeSelector,
     })
})

How it looks.

Show newly created button

Perfect.

Dropdown

Next up is the dropdown. I will use the withTooltip and TooltipLinkList components provided by storybook.

// ThemeSelector.tsx
import React from "react"
import {
     Icons,
     IconButton,
     TooltipLinkList,
     WithTooltip,
} from "@storybook/components"

const THEMES = ["Primary", "Dark", "Light"]

export const ThemeSelector = () => {
     return (
          <WithTooltip
               placement="top"
               trigger="click"
               closeOnClick
               tooltip={() => {
                    return (
                         <TooltipLinkList
                              links={THEMES.map((theme) => {
                                   return {
                                        id: theme,
                                        title: theme,
                                        active: false,
                                        value: theme,
                                   }
                              })}
                         />
                    )
               }}
          >
               <IconButton key="theme" title="Change theme">
                    <Icons icon="paintbrush" />
               </IconButton>
          </WithTooltip>
     )
}

Show newly created dropdown

Interactivity

I need to do several things here:

  1. Have a global variable of selected theme
  2. Update that global variable
  3. Change the data tag on the
    html
    element when it changes.

Luckily storybook supplies a global context which we can hook us into. Let's first make the attribute be the global variable and update when it changes.

// withTheme.ts
export const withTheme: DecoratorFunction = (StoryFn, context) => {
     const { globals } = context
     const theme = globals[THEME_ID]

     useEffect(() => {
          const querySelector = global.document.querySelector("html")
          if (querySelector) {
               querySelector.setAttribute("data-theme", theme)
          }
     }, [theme, context])

     return StoryFn()
}

Then we need to make the global to update when it is clicked.

// ThemeSelector.tsx
export const ThemeSelector = () => {
     const [globals, updateGlobals] = useGlobals()

     const onClick = (value: string) => {
          updateGlobals({
               [THEME_ID]: value,
          })
     }
     return (
          <WithTooltip
               placement="top"
               trigger="click"
               closeOnClick
               tooltip={() => {
                    return (
                         <TooltipLinkList
                              links={THEMES.map((theme) => {
                                   return {
                                        id: theme,
                                        title: theme,
                                        active: globals[THEME_ID] === theme,
                                        onClick: () => onClick(theme),
                                        value: theme,
                                   }
                              })}
                         />
                    )
               }}
          >
               <IconButton key="theme" title="Change theme">
                    <Icons icon="paintbrush" style={{ marginRight: "0.3em" }} />
                    {globals[THEME_ID]}
               </IconButton>
          </WithTooltip>
     )
}

I also added a default value for the global in

preset/preview.ts
.

export const globals = {
     [THEME_ID]: "primary",
}

Then I updated the boilerplate story of a button to be changed by the theme. Here is the css:

:root {
     --buttonBackgroundColor: #1ea7fd;
     --buttonTextColor: white;
}
[data-theme="dark"] {
     --buttonBackgroundColor: black;
}
[data-theme="light"] {
     --buttonBackgroundColor: yellow;
     --buttonTextColor: black;
}
.storybook-button--primary {
     color: var(--buttonTextColor);
     background-color: var(--buttonBackgroundColor);
}

Here it is in action

Show newly created dropdown

Theming via config

I want the user to provide a list of theme values and what the name of the data attribute. Storybook users can create a

preview.js
file that can provide config while I use useParameter to get the config from the user.

// ThemeSelector.tsx
const THEMES = {
     themes: ["primary", "dark", "light"],
     dataAttribute: "theme",
}

export const ThemeSelector = () => {
     const [globals, updateGlobals] = useGlobals()
     const themeConfig = useParameter(PARAM_KEY, THEMES)

     const onClick = (value: string) => {
          updateGlobals({
               [THEME_ID]: value,
          })
     }
     return (
          <WithTooltip
               placement="top"
               trigger="click"
               closeOnClick
               tooltip={() => {
                    return (
                         <TooltipLinkList
                              links={themeConfig.themes.map((theme) => {
                                   return {
                                        id: theme,
                                        title: theme,
                                        active: globals[THEME_ID] === theme,
                                        onClick: () => onClick(theme),
                                        value: theme,
                                   }
                              })}
                         />
                    )
               }}
          >
               <IconButton key="theme" title="Change theme">
                    <Icons icon="paintbrush" style={{ marginRight: "0.3em" }} />{" "}
                    // Fallbacks to themeconfig themes
                    {globals[THEME_ID] ?? themeConfig.themes[0]}
               </IconButton>
          </WithTooltip>
     )
}
export const withTheme: DecoratorFunction = (StoryFn, context) => {
     const { globals, parameters } = context

     // update globals theme if it does not exist
     if (!globals[THEME_ID]) {
          globals[THEME_ID] = parameters[PARAM_KEY].themes[0]
     }

     useEffect(() => {
          const querySelector = global.document.querySelector("html")
          if (querySelector) {
               querySelector.setAttribute(
                    `data-${parameters[PARAM_KEY].dataAttribute ?? "theme"}`,
                    globals[THEME_ID]
               )
          }
     }, [context, globals[THEME_ID]])

     return StoryFn()
}

The config is provided from the user from

storybook/preview.js

// preview.js
export const parameters = {
     themeSwitcher: {
          themes: ["primary", "dark", "light"],
          dataAttribute: "theme",
     },
}

Conclusion

  1. Add data-theme to the story container ✔️
  2. Create a button to switch theme ✔️
  3. Add themes via config ✔️
  4. make data-* tag to be customizable ✔️

You can look at the finished code at github or download the package from npm.