Design System Tutorial (Next.js)

Diego Ramos,design-system

Learn how to create a design system using Radix UI (opens in a new tab), TailwindCSS (opens in a new tab) and CVA (opens in a new tab).

Create a React.js project

We will be using Next.js (opens in a new tab) for this tutorial. You can use any other React framework.

Execute the following command to create a new Next.js project:

pnpm create next-app --ts --tailwind --eslint --src-dir --import-alias "@/*"

I have added the following flags to the command:

Feel free to remove any of these flags, these are just my personal recommadations.

If you have any problems, follow the official guide to create a new Next.js app (opens in a new tab).

Install dependencies

Add class-variance-authority (opens in a new tab) and tailwind-merge (opens in a new tab) as dev dependencies:

pnpm install -D class-variance-authority tailwind-merge

Light Mode & Dark Mode

If you're using Next.js and would like to add light mode and dark mode support, you can install next-themes (opens in a new tab) and set it up with TailwindCSS (opens in a new tab).

pnpm install next-themes

This is an optional recommadation.

Structure your design system

I like to structure my design systems like this:

.
├── public
├── src
   ├── pages
   ├── ui
      ├── radix
         ├── progress.tsx
         ├── dialog.tsx
         └── ...
      ├── html
         ├── button.tsx
         ├── card.tsx
         └── ...
      └── ...
   ├── tailwind.config.ts
   └── tsconfig.json

The UI components are stored in the ./src/ui directory.

Set colors

TailwindCSS lets you add colors by extending your color configuration or overwriting colors in your tailwind.config.ts.

Let's extend the color configuration (opens in a new tab) to add a brand color:

tailwind.config.ts
import type { Config } from "tailwindcss";
 
const config: Config = {
  // ...
  theme: {
    extend: {
      colors: {
        brand: {
          50: "#fdf2fb",
          100: "#fce8f9",
          200: "#fbd0f3",
          300: "#f9a9e9",
          400: "#f471d6",
          500: "#ec46c2",
          600: "#db29a6",
          700: "#bf1887",
          800: "#9b175b",
          900: "#81184e",
          950: "#50072d",
        },
      },
      // ...
    },
  },
};
 
module.exports = config;

We will use this brand color to explain how to create a component with custom colors.

Check out the Tailwind CSS documentation (opens in a new tab) for more information on how to customize colors.

Create a component

Let's first create a <Card/> component. Create a new file ./src/ui/html/Card.tsx:

Card.tsx
import * as React from "react";
 
type CardProps = React.HTMLAttributes<HTMLDivElement>;
 
function Card({ ...props }: CardProps) {
  return <div {...props} />;
}
 
// If you prefer, you can use an arrow function:
// const Card: React.FC<CardProps> = ({ ...props }) => <div {...props} />;
 
export { Card };
 
export type { CardProps };

Here we define a <Card/> component that renders a <div/> element. We also define the CardProps type. At this moment, the <Card/> component has the same props as any <div/> element.

Pro tip: If you hover over an element name in VSCode, you can see its type.

You will see this for a <div/> element:

(property) JSX.IntrinsicElements.div: React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>

You only need to extract the React.HTMLAttributes<HTMLDivElement> section for HTML elements.

Add variants

Define the card() variants with cva():

Card.tsx
import * as React from "react";
import { cva } from "class-variance-authority";
 
const card = cva("rounded-md shadow-sm w-full p-4", {
  variants: {
    variant: {
      outline: "border",
    },
    color: {
      slate: "bg-slate-50 dark:bg-slate-950",
    },
  },
});
 
type CardProps = React.HTMLAttributes<HTMLDivElement>;
 
function Card({ ...props }: CardProps) {
  return <div {...props} />;
}
 
export { Card };
 
export type { CardProps };

Here's what we have done:

You can name the variants however you want, as long as it doesn't create a conflict with an HTML attribute or component prop.

Extend the props

Doing the following <Card color="slate" /> won't work until we extend the props of the <Card/> component.

First, modify the CardProps type to extend the props of the <div/> to add variant and color as props by using VariantProps<typeof card>.

Now, destructure variant color and className from CardProps.

Finally, use twMerge() to merge the className and the output from card(variants). This ensures that we can use the variants, but still be able to add custom classes to the component, which will overwrite any classes from the variants.

Card.tsx
import * as React from "react";
import { type VariantProps, cva } from "class-variance-authority";
import { twMerge } from "tailwind-merge";
 
const card = cva("rounded-md shadow-sm w-full p-4", {
  variants: {
    variant: {
      outline: "border",
    },
    color: {
      slate: "bg-slate-50 dark:bg-slate-950",
    },
  },
});
 
type CardProps = React.HTMLAttributes<HTMLDivElement> &
  VariantProps<typeof card>;
 
function Card({ className, variant, color, ...props }: CardProps) {
  return (
    <div {...props} className={twMerge(card({ variant, color }), className)} />
  );
}
 
export { Card };
 
export type { CardProps };

Now that we have extended the props to add the cva variants, we can use the <Card/> component like this:

<Card variant="outline" />
<Card color="slate" />

Which outputs:

<div class="w-full rounded-md border p-4" />
<div class="w-full rounded-md bg-slate-50 p-4 dark:bg-slate-950" />

The best part of our new <Card/> is that we can pass any additional classes in JSX:

<Card color="slate" className="bg-red-500" />

This will render a <div/> where the "bg-slate-500" class is overwritten the "bg-red-50" class.

<div className="bg-red-500 dark:bg-slate-950" />

This is possible because we used twMerge() to merge the classes.

If you are confused about twMerge(), check out the tailwind-merge documentation (opens in a new tab).

Add more variants

Card.tsx
import * as React from "react";
import { type VariantProps, cva } from "class-variance-authority";
import { twMerge } from "tailwind-merge";
 
const card = cva("rounded-md shadow-sm w-full p-4 border", {
  variants: {
    variant: {
      outline: "",
      plane: "border-transparent",
    },
    color: {
      slate: "bg-slate-50 dark:bg-slate-950",
    },
  },
  compoundVariants: [
    {
      color: "slate",
      variant: "outline",
      className: "border-slate-300 dark:border-slate-800",
    },
  ],
  defaultVariants: {
    variant: "outline",
    color: "slate",
  },
});
 
type CardProps = React.HTMLAttributes<HTMLDivElement> &
  VariantProps<typeof card>;
 
function Card({ className, variant, color, ...props }: CardProps) {
  return (
    <div {...props} className={twMerge(card({ variant, color }), className)} />
  );
}
 
export { Card };
 
export type { CardProps };

Here's what we have done:

  1. Moved the "border" class from the outline variant to the base classes. This is because we want the "border" class to be applied to the component when the plane variant is used.

  2. Added the plane variant to the variant variant, which makes the border transparent.

This is now valid:

<Card variant="plane" />
  1. Added compoundVariants which allows us to create a variant that is dependent on other variants. In this case, we want "border-slate-300 dark:border-slate-800" to be applied to the component when color="slate" and variant="outline". This will be used as follows:
<Card color="slate" variant="outline" />
  1. Added the defaultVariants which set the default variants of the component. We set the default variant to outline and the default color to slate.

<Card color="slate" variant="outline" /> and <Card /> will now render the same component.

Add custom colors

Since we had previously extended the tailwindcss colors to a brand color, we can now use the brand color in the <Card/> component.

Card.tsx
import * as React from "react";
import { type VariantProps, cva } from "class-variance-authority";
import { twMerge } from "tailwind-merge";
 
const card = cva("rounded-md shadow-sm w-full p-4 border", {
  variants: {
    variant: {
      outline: "",
      plane: "border-transparent",
    },
    color: {
      brand: "bg-brand-50 dark:bg-brand-950",
      slate: "bg-slate-50 dark:bg-slate-950",
    },
  },
  compoundVariants: [
    {
      color: "brand",
      variant: "outline",
      className: "border-brand-300 dark:border-brand-800",
    },
    {
      color: "slate",
      variant: "outline",
      className: "border-slate-300 dark:border-slate-800",
    },
  ],
  defaultVariants: {
    variant: "outline",
    color: "slate",
  },
});
 
type CardProps = React.HTMLAttributes<HTMLDivElement> &
  VariantProps<typeof card>;
 
function Card({ className, variant, color, ...props }: CardProps) {
  return (
    <div {...props} className={twMerge(card({ variant, color }), className)} />
  );
}
 
export { Card };
 
export type { CardProps };

An example demo app could look like this:

import { Card } from "@/ui/html/card";
 
export default function CardDemo() {
  return <Card className="h-[100px] w-[300px]" color="brand" />;
}

Notice how we import the components using "@/ui/html/card" thanks to the tsconfig.json setup we created a the beginning of this guide.

Congrats, you now have the foundation to create any component with variants!

Now to take it to the next level, let's learn how to do the same with Radix components.

Add variants to a Radix component

Let's create a custom progress bar using Radix. First we need to install the @radix-ui/react-progress package.

pnpm install @radix-ui/react-progress

Now create progress.tsx in the ./src/ui/radix directory.

Here we have the initial setup with the <Root/> and <Indicator/> components. See the Radix API reference (opens in a new tab) for this component for more details.

progress.tsx
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
 
type RootProps = React.ComponentProps<typeof ProgressPrimitive.Root>;
 
function Root({ ...props }: IndicatorProps) {
  return <ProgressPrimitive.Root {...props} />;
}
 
type IndicatorProps = React.ComponentProps<typeof ProgressPrimitive.Indicator>;
 
function Indicator({ ...props }: IndicatorProps) {
  return <ProgressPrimitive.Indicator {...props} />;
}
 
export { Root, Indicator };
 
export type { RootProps, IndicatorProps };

Notice we created the RootProps and IndicatorProps types. As of now they have the same props as their respective Radix components, but we will add the variants props in the next step.

Now create the variants for the root and the indicator and extend the RootProps and IndicatorProps types.

progress.tsx
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { type VariantProps, cva } from "class-variance-authority";
import { twMerge } from "tailwind-merge";
 
const root = cva(
  `relative
  h-5
  w-[300px]
  overflow-hidden
  rounded-full
  [transform:translateZ(0)]`,
  {
    variants: {
      color: {
        brand: "bg-brand-50 dark:bg-brand-950",
        slate: "bg-slate-50 dark:bg-slate-950",
      },
    },
    defaultVariants: {
      color: "slate",
    },
  }
);
 
type RootProps = React.ComponentProps<typeof ProgressPrimitive.Root> &
  VariantProps<typeof root>;
 
function Root({ className, color, ...props }: IndicatorProps) {
  return (
    <ProgressPrimitive.Root
      {...props}
      className={twMerge(root({ color }), className)}
    />
  );
}
 
const indicator = cva(
  `h-full
    w-full
    transition-transform
    duration-[660ms]
    ease-[cubic-bezier(0.65,0,0.35,1)]`,
  {
    variants: {
      color: {
        brand: "bg-brand-500 dark:bg-brand-600",
        slate: "bg-slate-500 dark:bg-slate-600",
      },
    },
    defaultVariants: {
      color: "slate",
    },
  }
);
 
type IndicatorProps = React.ComponentProps<typeof ProgressPrimitive.Indicator> &
  VariantProps<typeof indicator>;
 
function Indicator({ className, color, ...props }: IndicatorProps) {
  return (
    <ProgressPrimitive.Indicator
      {...props}
      className={twMerge(indicator({ color }), className)}
    />
  );
}
 
export { Root, Indicator };
 
export type { RootProps, IndicatorProps };

Now you can use these components in your app. Create a new file progress.tsx in the ./src/pages directory.

progress.tsx
import * as React from "react";
import * as Progress from "@/ui/radix/progress";
 
// You may also import the components individually
// import { Root, Indicator } from "@/ui/radix/progress";
 
export default function ProgressDemo() {
  const [progress, setProgress] = React.useState(13);
 
  React.useEffect(() => {
    const timer = setTimeout(() => setProgress(66), 500);
    return () => clearTimeout(timer);
  }, []);
 
  return (
    <Progress.Root value={66}>
      <Progress.Indicator
        style={{ transform: `translateX(-${100 - progress}%)` }}
      />
    </Progress.Root>
  );
}

Notice how we import the components using "@/ui/radix/progress" thanks to the tsconfig.json setup we created a the beginning of this guide.

tsconfig.json
{
  "compilerOptions": {
    ...
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

Creating complex components (forwardRef)

In some cases you may want to simplify the Radix API or create your own complex custom components and you may need to use React.forwardRef() to forward the ref to the underlying DOM element.

Let's use the @radix-ui/react-dialog component as an example. We will create a new dialog.tsx in the ./src/ui/radix directory.

We'll skip the styling/variants and focus on React.forwardRef().

dialog.tsx
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { type VariantProps, cva } from "class-variance-authority";
import { twMerge } from "tailwind-merge";
 
type RootProps = React.ComponentProps<typeof DialogPrimitive.Root>;
const Root = DialogPrimitive.Root;
 
type TriggerProps = React.ComponentProps<typeof DialogPrimitive.Trigger>;
const Trigger = DialogPrimitive.Trigger;
 
type PortalProps = React.ComponentProps<typeof DialogPrimitive.Portal>;
const Portal = DialogPrimitive.Portal;
 
type TitleProps = React.ComponentProps<typeof DialogPrimitive.Title>;
const Title = DialogPrimitive.Title;
 
type DescriptionProps = React.ComponentProps<
  typeof DialogPrimitive.Description
>;
const Description = DialogPrimitive.Description;
 
type CloseProps = React.ComponentProps<typeof DialogPrimitive.Close>;
const Close = DialogPrimitive.Close;
 
// Insert the base styles and variants for the <Overlay/> component here
const overlay = cva();
 
type OverlayProps = React.ComponentProps<typeof DialogPrimitive.Overlay> &
  VariantProps<typeof overlay>;
 
function Overlay({ className, ...props }: OverlayProps) {
  return (
    <DialogPrimitive.Overlay
      {...props}
      className={twMerge(overlay(), className)}
    />
  );
}
 
// Insert the base styles and variants for the <Content/> component here
const content = cva();
 
type ContentProps = React.ComponentPropsWithoutRef<
  typeof DialogPrimitive.Content
> &
  VariantProps<typeof content>;
 
const Content = React.forwardRef<
  React.ElementRef<typeof DialogPrimitive.Content>,
  ContentProps
>(({ className, ...props }, ref) => (
  <Portal>
    <Overlay />
    <DialogPrimitive.Content
      {...props}
      ref={ref}
      className={twMerge(content(), className)}
    />
  </Portal>
));
Content.displayName = DialogPrimitive.Content.displayName;
 
export { Root, Trigger, Content, Title, Description, Portal, Overlay, Close };
 
export type {
  RootProps,
  TriggerProps,
  ContentProps,
  TitleProps,
  DescriptionProps,
  PortalProps,
  OverlayProps,
  CloseProps,
};

Here we have created a new <Content/> component that extends the <DialogPrimitive.Content/> component which contains the <Portal the <Overlay and the <Content/> itself.

Note: We use

Content.displayName = DialogPrimitive.Content.displayName;

to explicitly set the displayName of the component. This ensures that the component will be displayed with the correct name when debugging.

Now you can use the Dialog primitive like this:

import * as Dialog from "@/ui/radix/dialog";
 
export function DialogDemo() {
  return (
    <Dialog.Root>
      <Dialog.Trigger>Open Dialog</Dialog.Trigger>
 
      <Dialog.Content>{/* Insert content here */}</Dialog.Content>
    </Dialog.Root>
  );
}

Instead of the following:

import * as Dialog from "@/ui/radix/dialog";
 
export function DialogDemo() {
  return (
    <Dialog.Root>
      <Dialog.Trigger>Open Dialog</Dialog.Trigger>
 
      <Dialog.Portal>
        <Dialog.Overlay />
        <Dialog.Content>{/* Insert content here */}</Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}

Notice you won't be able to target the <Portal/> and <Overlay/> components with the new <Content/> component.

If you want to be more flexible in your codebase you can export a clean <Content/> and a <MergedContent/> component which groups <Portal/> and <Overlay/> and <Content/> with forwardRef(). This way you can decide in a per-component basis wether it makes sense to use the <Content/> or the <MergedContent/> component.

Read the React documentation on React.forwardRef() (opens in a new tab) for more information.

Now go ahead and create your design systems! 🎉

Feel free to ask me any questions, share your work or give me feedback on Twitter (opens in a new tab). I'm always happy to help!