DEV Community

Cover image for Design System in React with Tailwind, Shadcn/ui and Storybook
Shaikat Haque
Shaikat Haque

Posted on

Design System in React with Tailwind, Shadcn/ui and Storybook

💡 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
  2. Storybook
  3. Theming
  4. Dark Mode
  5. Typography
  6. 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 and Shadcn UI 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.


TailwindCSS and Shadcn UI Installation

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 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.

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

You should then be able to access storybook by running the storybook script and navigating to 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.

/* .storybook/preview.ts */
import '../src/index.css';
Enter fullscreen mode Exit fullscreen mode

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

Assuming you have the Shadcn UI button component added with:

npx shadcn-ui@latest add button
Enter fullscreen mode Exit fullscreen mode

Here is a basic button story:

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
*/
Enter fullscreen mode Exit fullscreen mode

Here’s what is going on in this file:

  • Following the Atomic Web Design principles, 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


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.

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:

--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
Enter fullscreen mode Exit fullscreen mode

--primary will be used as the background color

--primary-foreground will be the text color

Usage:

<div className="bg-primary text-primary-foreground">This is a primary component</div>
Enter fullscreen mode Exit fullscreen mode

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/

💡 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


Dark Mode

Shadcn UI provides clear instructions on how to add dark mode to a site: 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.

npm i -D @storybook/addon-themes
Enter fullscreen mode Exit fullscreen mode

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

/* .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;
Enter fullscreen mode Exit fullscreen mode

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

/* .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;
Enter fullscreen mode Exit fullscreen mode

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


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.

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:

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:

/* 
./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 };
Enter fullscreen mode Exit fullscreen mode

Example Usage:

<Text variant="h1">Heading H1</Text>
<Text variant="p">Paragraph text</Text>
Enter fullscreen mode Exit fullscreen mode

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.

Add to Storybook

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

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

/* ./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 */
Enter fullscreen mode Exit fullscreen mode

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

/* ./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} />
Enter fullscreen mode Exit fullscreen mode

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

Image description

Custom Fonts

Documentation on setting custom fonts can be found here: 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.

/* index.css */

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

Override default fonts in tailwind.config.js.

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

module.exports = {
  theme: {
    extend: {
      fontFamily: {
        'sans': ['"Quicksand"', ...defaultTheme.fontFamily.sans],
      },
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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


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 as the foundation for the design system, it can vastly improve the development experience.

The source code for this post is available in Github. 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.

Top comments (0)