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.

1// withTheme.ts 2import type { DecoratorFunction } from "@storybook/addons" 3import { useEffect } from "@storybook/addons" 4 5export const withTheme: DecoratorFunction = (StoryFn) => { 6 useEffect(() => { 7 const querySelector = global.document.querySelector("html") 8 if (querySelector) { 9 querySelector.setAttribute("data-theme", "kent") 10 } 11 }, []) 12 13 return StoryFn() 14}
1// preset/preview.ts 2import { withTheme } from "../withTheme" 3 4export 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.

1// ThemeSelector.tsx 2import React from "react" 3import { Icons, IconButton } from "@storybook/components" 4 5export const ThemeSelector = () => { 6 return ( 7 <IconButton key="theme" title="Change theme"> 8 <Icons icon="paintbrush" /> 9 </IconButton> 10 ) 11}

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.

1// preset/manager.ts 2import { addons, types } from "@storybook/addons" 3import { ADDON_ID, THEME_ID } from "../constants" 4import { ThemeSelector } from "../ThemeSelector" 5 6// Register the addon 7addons.register(ADDON_ID, () => { 8 // Register theme 9 addons.add(THEME_ID, { 10 type: types.TOOL, 11 title: "Theme Selector", 12 match: ({ viewMode }) => 13 !!(viewMode && viewMode.match(/^(story|docs)$/)), 14 render: ThemeSelector, 15 }) 16})

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.

1// ThemeSelector.tsx 2import React from "react" 3import { 4 Icons, 5 IconButton, 6 TooltipLinkList, 7 WithTooltip, 8} from "@storybook/components" 9 10const THEMES = ["Primary", "Dark", "Light"] 11 12export const ThemeSelector = () => { 13 return ( 14 <WithTooltip 15 placement="top" 16 trigger="click" 17 closeOnClick 18 tooltip={() => { 19 return ( 20 <TooltipLinkList 21 links={THEMES.map((theme) => { 22 return { 23 id: theme, 24 title: theme, 25 active: false, 26 value: theme, 27 } 28 })} 29 /> 30 ) 31 }} 32 > 33 <IconButton key="theme" title="Change theme"> 34 <Icons icon="paintbrush" /> 35 </IconButton> 36 </WithTooltip> 37 ) 38}

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.

1// withTheme.ts 2export const withTheme: DecoratorFunction = (StoryFn, context) => { 3 const { globals } = context 4 const theme = globals[THEME_ID] 5 6 useEffect(() => { 7 const querySelector = global.document.querySelector("html") 8 if (querySelector) { 9 querySelector.setAttribute("data-theme", theme) 10 } 11 }, [theme, context]) 12 13 return StoryFn() 14}

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

1// ThemeSelector.tsx 2export const ThemeSelector = () => { 3 const [globals, updateGlobals] = useGlobals() 4 5 const onClick = (value: string) => { 6 updateGlobals({ 7 [THEME_ID]: value, 8 }) 9 } 10 return ( 11 <WithTooltip 12 placement="top" 13 trigger="click" 14 closeOnClick 15 tooltip={() => { 16 return ( 17 <TooltipLinkList 18 links={THEMES.map((theme) => { 19 return { 20 id: theme, 21 title: theme, 22 active: globals[THEME_ID] === theme, 23 onClick: () => onClick(theme), 24 value: theme, 25 } 26 })} 27 /> 28 ) 29 }} 30 > 31 <IconButton key="theme" title="Change theme"> 32 <Icons icon="paintbrush" style={{ marginRight: "0.3em" }} /> 33 {globals[THEME_ID]} 34 </IconButton> 35 </WithTooltip> 36 ) 37}

I also added a default value for the global in preset/preview.ts.

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

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

1:root { 2 --buttonBackgroundColor: #1ea7fd; 3 --buttonTextColor: white; 4} 5[data-theme="dark"] { 6 --buttonBackgroundColor: black; 7} 8[data-theme="light"] { 9 --buttonBackgroundColor: yellow; 10 --buttonTextColor: black; 11} 12.storybook-button--primary { 13 color: var(--buttonTextColor); 14 background-color: var(--buttonBackgroundColor); 15}

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.

1// ThemeSelector.tsx 2const THEMES = { 3 themes: ["primary", "dark", "light"], 4 dataAttribute: "theme", 5} 6 7export const ThemeSelector = () => { 8 const [globals, updateGlobals] = useGlobals() 9 const themeConfig = useParameter(PARAM_KEY, THEMES) 10 11 const onClick = (value: string) => { 12 updateGlobals({ 13 [THEME_ID]: value, 14 }) 15 } 16 return ( 17 <WithTooltip 18 placement="top" 19 trigger="click" 20 closeOnClick 21 tooltip={() => { 22 return ( 23 <TooltipLinkList 24 links={themeConfig.themes.map((theme) => { 25 return { 26 id: theme, 27 title: theme, 28 active: globals[THEME_ID] === theme, 29 onClick: () => onClick(theme), 30 value: theme, 31 } 32 })} 33 /> 34 ) 35 }} 36 > 37 <IconButton key="theme" title="Change theme"> 38 <Icons icon="paintbrush" style={{ marginRight: "0.3em" }} />{" "} 39 // Fallbacks to themeconfig themes 40 {globals[THEME_ID] ?? themeConfig.themes[0]} 41 </IconButton> 42 </WithTooltip> 43 ) 44}
1export const withTheme: DecoratorFunction = (StoryFn, context) => { 2 const { globals, parameters } = context 3 4 // update globals theme if it does not exist 5 if (!globals[THEME_ID]) { 6 globals[THEME_ID] = parameters[PARAM_KEY].themes[0] 7 } 8 9 useEffect(() => { 10 const querySelector = global.document.querySelector("html") 11 if (querySelector) { 12 querySelector.setAttribute( 13 `data-${parameters[PARAM_KEY].dataAttribute ?? "theme"}`, 14 globals[THEME_ID] 15 ) 16 } 17 }, [context, globals[THEME_ID]]) 18 19 return StoryFn() 20}

The config is provided from the user from storybook/preview.js

1// preview.js 2export const parameters = { 3 themeSwitcher: { 4 themes: ["primary", "dark", "light"], 5 dataAttribute: "theme", 6 }, 7}

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.