Design System in React with Tailwind, Shadcn/ui and Storybook
Published: 2024-05-13 06:45:46



> 💡 The source code for this post is available on Github: https://github.com/shaikathaque/design-system-tailwind-shadcn-storybook

---

## Table of Contents
1. [TailwindCSS and Shadcn UI Installation
](#tailwind-shadcn)
2. [Storybook](#storybook)
3. [Theming](#theming)
4. [Dark Mode](#dark-mode)
5. [Typography](#typography)
6. [Conclusion](#conclusion)

---

Setting up a design system as early as possible can save a lot of time in the long run for an organization and its projects. It is important for engineers to collaborate with designers to establish a design system and library to provide a consistent brand and UX, while improving the developer experience and speed.

This post specifically talks about setting up a design system in a Frontend React project that uses [TailwindCSS](https://tailwindcss.com/) and [Shadcn UI](https://ui.shadcn.com/) components. The following topics will be covered:

- Storybook
- Theme
- Dark Mode
- Typography

This post won’t be a step by step guide, as technologies change over time and things break. Instead, it provides a big picture, and links to relevant setup guides. 

---

<a name="tailwind-shadcn"></a>
## TailwindCSS and Shadcn UI Installation

[https://ui.shadcn.com/docs/installation/vite](https://ui.shadcn.com/docs/installation/vite) provides instructions to set up TailwindCSS and Shadcn UI in a React with Vite.

In the `npx shadcn-ui@latest init` step, when asked `Do you want to use CSS variables for colors?`, choose `Yes`, as this will allow us to leverage css variables for theming.

The following files are created/modified:

1. `components.json` - This holds configuration for `Shadcn UI`. This does not need to be updated. You can check [https://ui.shadcn.com/schema.json](https://ui.shadcn.com/schema.json) to view its `jsonSchema`.
2. `index.css` - This is updated with `CSS Variables` that apply colors.
    * `:root` selector defines variables for colors to be used by default.
    * `.dark` class specifies the variables for colors to use when dark mode is enabled.
3. `tailwind.config.js` - This file is updated to extend some of the default `TailwindCSS` styles with custom styles, such as colors, border radius, etc. The `CSS Variables` defined in `index.css` are leveraged here.

---

<a name="storybook"></a>
## Storybook Installation

Before we start making any code changes, it is very helpful to setup storybook to easily view changes to our components and theme without writing any layout logic. There are some additional steps needed to make storybook work with Shadcn UI components and dark mode.


> ⚠️ As of writing, I am using Storybook 8. The syntax and API might change in the future.


> 🔥 Credit to https://github.com/JheanAntunes/storybook-shadcn for having this setup which I could use as a reference.


Follow the Storybook Installation Instructions: [https://storybook.js.org/docs/get-started/install](https://storybook.js.org/docs/get-started/install)

You should then be able to access storybook by running the `storybook` script and navigating to [`http://localhost:6006/`](http://localhost:6006/). There will be some example components by default. Feel free to remove them.

The following files are added by the initialization script:

1. `.storybook/main.ts` - This holds storybook configuration such as location of stories, and any add-ons that are installed.
2. `.storybook/preview.ts` - This file allows controlling how stories are rendered, such as adding CSS or decorators for themes.
3. A `stories` directory which will contain our stories.

To get storybook working with Tailwind CSS and Shadcn UI, we must import the global css file in the `preview.ts` to allow Shadcn UI components to be styled correctly in Storybook.

```jsx
/* .storybook/preview.ts */
import '../src/index.css';
```

Now we can start writing stories. Let’s start with a basic button story.

Assuming you have the Shadcn UI button component added with: 

```bash
npx shadcn-ui@latest add button
```

Here is a basic button story:

```jsx
import { Meta, StoryObj } from '@storybook/react';
import { Button } from '@/components/ui/button';

const meta = {
  title: 'Atoms/button',
  component: Button,
  tags: ['autodocs'],
  argTypes: {
    variant: {
      control: { type: 'select' },
      options: [
        'default',
        'secondary',
        'destructive',
        'ghost',
        'link',
        'outline',
      ],
    },
    size: {
      control: { type: 'select' },
      options: ['default', 'icon', 'sm', 'lg'],
    },
  },
  parameters: {
    layout: 'centered',
  },
} satisfies Meta<typeof Button>;

export default meta;

type Story = StoryObj<typeof meta>;

// Button Variants

// <Button variant="default">Default</Button>
export const Default: Story = {
  args: {
    variant: 'default',
    children: 'Default',
  },
};

// <Button variant="secondary">Secondary</Button>
export const Secondary: Story = {
  args: {
    variant: 'secondary',
    children: 'Secondary',
  },
};

/*
	exports for all other variants
*/
```

Here’s what is going on in this file:

- Following the [Atomic Web Design principles](https://bradfrost.com/blog/post/atomic-web-design/), the `title` of the `meta` object is set to `atoms/button`, which will organize the `button` stories under a section named `Atoms`.
- `meta` - metadata to configure stories for a component. This object is the default export.
  * here we specify the arguments that can be passed to the shadcn ui components, such as `variant, size, etc`.
- Button stories - each object of type `Story` that is exported represents aa story. We can create a story for each way a component is used.

Since the button component renders the label based on the string passed in as its children element, we can specify the label as the `children` argument.

You should now be able to see the Button stories in Storybook.



![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/wj7sskkwqf2sk2lpig2x.png)




---

<a name="theming"></a>
## Theming

Now that storybook is set up, we can easily explore themes and dark mode in our project.

Theming is set up by shadcn ui out of the box. The `css` variables in the `index.css` can be updated for setting the theme. New variables can also be added. You can learn more about it here: [https://ui.shadcn.com/docs/theming](https://ui.shadcn.com/docs/theming).

The key thing to keep in mind is that a `background` and `foreground` convention is used for naming. However, the `background` word is not used in the variable name.

For example, given our primary colors are set as:

```css
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
```

`--primary` will be used as the background color

`--primary-foreground` will be the text color

Usage:

```jsx
<div className="bg-primary text-primary-foreground">This is a primary component</div>
```

This convention is used for all variations - `secondary, muted, accent, etc`

Shadcn recommends using `hsl` colors for theming. [https://www.smashingmagazine.com/2021/07/hsl-colors-css/](https://www.smashingmagazine.com/2021/07/hsl-colors-css/)

> 💡 Tip: I highly recommend using VS code extension for highlighting hsl colors. For example, I use the Color Highlight extension to show the color of each hsl variable. After installing, go to extension settings > enable `match hsl with no function`. Update marker type to something other than background. I like the option: `dot-after`


---
<a name="dark-mode"></a>
## Dark Mode

Shadcn UI provides clear instructions on how to add dark mode to a site: [https://ui.shadcn.com/docs/dark-mode](https://ui.shadcn.com/docs/dark-mode). However, additional steps have to be taken to enable Dark Mode in our components in Storybook.

Dark mode works in Shadcn UI but applying a class to the root element in the document tree.

In `light` mode, the `html` element will have `class="light`".  In this case, the `css variables` in the `:root` of our `index.css` will be applied.

In `dark` mode, the `html` element will have `class="dark"`. In this case, the `css variables` in the `.dark` class of our `index.css` will be applied.

Since storybook does not run within our app, we need to configure add-ons so that the `dark` and `light` classes can be applied.

To support this, install the add-on `@storybook/addon-themes`. Instructions are available here https://github.com/storybookjs/storybook/blob/next/code/addons/themes/docs/getting-started/tailwind.md. 

```bash
npm i -D @storybook/addon-themes
```

Then update `.storybook.main.ts` to configure storybook with this `@storybook/addon-themes` .

```jsx
/* .storybook/main.ts */
import type { StorybookConfig } from "@storybook/react-vite";

const config: StorybookConfig = {
  /* existing properties */
  addons: [
    /* existing add-ons */
    '@storybook/addon-themes',
  ],
  /* remaining properties */
};
export default config;
```

Then, we update `.storybook/preview.ts` to add remove classes as needed.

```jsx
/* .storybook/preview.ts */
// existing imports

import { withThemeByClassName } from '@storybook/addon-themes';
import '../src/index.css';

const preview: Preview = {
  parameters: {
    // existing properties
    darkMode: {
      classTarget: 'html',
      darkClass: 'dark',
      lightClass: 'light',
    }
  },
  decorators:[
    withThemeByClassName({
      themes: {
        light: 'light',
        dark: 'dark',
      },
      defaultTheme: 'light'
    })
  ]
};

export default preview;
```

A few things are happening here:

- We are importing a decorator `withThemeByClassName` from `@storybook/addon-themes` which will be used to configure themes in storybook using class names.
- We are specifying the following `parameters` for `darkMode`:
    - `classTarget` - the element to which classes for themes should be applied - in this case the `html` element.
    - `darkClass` - the class name to use for `dark` mode.
    - `lightClass` - the class name to use for `light` mode.
- Use the `withThemeByClassName` decorator to specify the available themes.
    - In the `themes` object, the key is the theme name, and the value is the theme class name.

We should now be able to toggle between `dark theme` and `light theme` in storybook.


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/k3qax6v9zbkayqsvmnc4.png)


---

<a name="typography"></a>
## Typography

Setting up typography from the start can save time in the long run rather than copy pasting font styles in multiple places in your code. A design system will ideally have a standardize set of text styles, such as `heading`, `subheading`, `body`, etc which will be reused throughout the UI for a consistent UX.

Since Shadcn UI is not a component library, it does not provide a typography component like other UI libraries such as `NextUI, MUI, ChakraUI` do. However, it does provide [recommendations on how to style text](https://ui.shadcn.com/docs/components/typography).

I personally like the pattern of having a reusable `Text` component that can be passed in a variant and overridden with custom styles in one-off scenarios. This is a common scenario and there have been multiple discussion in the Shadcn UI community for such a solution:

- https://github.com/shadcn-ui/ui/issues/315
- https://github.com/shadcn-ui/ui/pull/363

Based on the pattern followed in Shadcn UI’s Button component, along with the discussions in Github, here is an example of a `Text` component for our design system:

```tsx
/* 
./src/components/ui/text.tsx 
source: https://github.com/shadcn-ui/ui/pull/363#issuecomment-1659259897
*/

import * as React from 'react';
import { VariantProps, cva } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { Slot } from '@radix-ui/react-slot';

const textVariants = cva('text-foreground', {
    variants: {
        variant: {
            h1: 'scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl',
            h2: 'scroll-m-20 pb-2 text-3xl font-semibold tracking-tight',
            h3: 'scroll-m-20 text-2xl font-semibold tracking-tight',
            h4: 'scroll-m-20 text-xl font-semibold tracking-tight',
            p: 'leading-7 [&:not(:first-child)]:mt-6',
            lead: 'text-xl text-muted-foreground',
            large: 'text-lg font-semibold',
            small: 'text-sm font-medium leading-none',
            muted: 'text-sm text-muted-foreground',
        },
    },
    defaultVariants: {
        variant: "p",
    },
});

type VariantPropType = VariantProps<typeof textVariants>;

const variantElementMap: Record<
    NonNullable<VariantPropType['variant']>,
    string
> = {
    h1: 'h1',
    h2: 'h2',
    h3: 'h3',
    h4: 'h4',
    p: 'p',
    lead: 'p',
    large: 'div',
    small: 'small',
    muted: 'p',
};

type TextElement = 'h1' | 'h2' | 'h3' | 'h4' | 'p' | 'small' | 'div';

export interface TextProps
    extends React.HTMLAttributes<HTMLElement>,
        VariantProps<typeof textVariants> {
    asChild?: boolean;
    as?: TextElement;
}

const Text = React.forwardRef<HTMLElement, TextProps>(
    ({ className, variant, as, asChild, ...props }, ref) => {
        const Comp = asChild ? Slot : as ?? (variant ? variantElementMap[variant] : undefined) ?? 'div';
        return (
            <Comp
                className={cn(textVariants({ variant, className }))}
                ref={ref}
                {...props}
            />
        );
    }
);
Text.displayName = 'Text';

export { Text, textVariants };
```

Example Usage:

```tsx
<Text variant="h1">Heading H1</Text>
<Text variant="p">Paragraph text</Text>
```

The variants (`h1, h2, p1`) can be modified in `Text.tsx` to meet our design system  requirements.

Additionally, if we want to modify Tailwind’s default classes for fontSize (`2xl, xl, lg, sm,`) we can do so  with custom `fontSize, lineHeight, letterSpacing, and fontWeight`. Instructions can be found here: [https://tailwindcss.com/docs/font-size#providing-a-default-line-height](https://tailwindcss.com/docs/font-size#providing-a-default-line-height).

<br><br>

### Add to Storybook

We can document example usage of our Typography components in Storybook.

To do so, first create a story `Text.stories.tsx`

```jsx
/* ./src/stories/Text.stories.tsx */

import { Meta, StoryObj } from '@storybook/react';
import { Text } from '@/components/ui/text';

const meta = {
  title: 'Text',
  component: Text,
  argTypes: {
    variant: {
      control: { type: 'select' },
      options: ['h1', 'h2', 'h3', 'h4', 'p', 'small'],
    },
    as: {
      control: { type: 'select' },
      options: ['h1', 'h2', 'h3', 'h4', 'h5', 'p'],
    },
  },
  parameters: {
    layout: 'centered',
  },
} satisfies Meta<typeof Text>;

export default meta;

type Story = StoryObj<typeof meta>;

export const H1: Story = {
  args: {
    variant: 'h1',
    children: 'Heading 1',
  },
};

export const H2: Story = {
  args: {
    variant: 'h2',
    children: 'Heading 2',
  },
};

/* Other variants */
```

<br>

Add an `.mdx`  page to view all Typography components together:

```md
/* ./src/stories/Text.mdx */

import { Canvas, Meta, Primary } from '@storybook/blocks';

import * as TextStories from './Text.stories';

<Meta of={TextStories} />

## Text

The Text component renders text based on a provided variant.

<Primary />

<Canvas of={TextStories.H2} />
<Canvas of={TextStories.H3} />
<Canvas of={TextStories.H4} />
<Canvas of={TextStories.H4} />
<Canvas of={TextStories.P} />
<Canvas of={TextStories.Lead} />
<Canvas of={TextStories.Large} />
<Canvas of={TextStories.Small} />
<Canvas of={TextStories.Muted} />
```

<br>

You should now be able to view the all possible Text variants in Storybook.



![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/vz3fyruoqbpzz7lrqmn4.png)


<br>

### Custom Fonts

Documentation on setting custom fonts can be found here: [https://tailwindcss.com/docs/font-family#using-custom-values](https://tailwindcss.com/docs/font-family#using-custom-values)

#### Download and import the font

You can import fonts from remote target source, or download it to your project. Each has its pros and cons. For this example, I’ll use a downloaded font.

Download `.tff` file from a trusted source and place in `/fonts`. Then import the font in `index.css` using `@font-face`.

```css
/* index.css */

@layer base {
  * {
    @apply border-border;
    @font-face {
      font-family: 'Quicksand';
      src: url('/fonts/Quicksand.tff');
    }
  }
  body {
    @apply bg-background text-foreground;
  }
}
```

Override default fonts in `tailwind.config.js`.

```js
/* tailwind.config.js */
const defaultTheme = require('tailwindcss/defaultTheme')

module.exports = {
  theme: {
    extend: {
      fontFamily: {
        'sans': ['"Quicksand"', ...defaultTheme.fontFamily.sans],
      },
    }
  }
}
```

Now, the `Quicksand` font family will be used by default.

---

<a name="conclusion"></a>
## Conclusion

Setting up a design system can take a bit of work but having it set up from the start can increase development speed and code maintainability in the long run. It is important for engineering to work closely with design to ensure coherency in the design system. Especially if the design team uses something like a [Shadcn UI figma file](https://www.figma.com/community/file/1203061493325953101) as the foundation for the design system, it can vastly improve the development experience.

The source code for this post is available in [Github](https://github.com/shaikathaque/design-system-tailwind-shadcn-storybook). Please feel free to reach out if there are any questions or confusions about the content of this post, and I will do my best to respond.