Design System Tutorial (Next.js)
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:
--ts
: Use TypeScript.--tailwind
: Use Tailwind CSS.--eslint
: Use ESLint.--src-dir
: Use./src
as the source directory.--import-alias "@/*"
: Use@
as the import alias for the./src
directory.
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:
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
:
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()
:
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:
- Set the base tailwind classes to
"rounded-md w-full p-4"
. These classes will always be applied to the component. - Create the variants object and add the
outline
andvariant
variants.
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.
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
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:
-
Moved the
"border"
class from theoutline
variant to the base classes. This is because we want the"border"
class to be applied to the component when theplane
variant is used. -
Added the
plane
variant to thevariant
variant, which makes the border transparent.
This is now valid:
<Card variant="plane" />
- 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 whencolor="slate"
andvariant="outline"
. This will be used as follows:
<Card color="slate" variant="outline" />
- Added the
defaultVariants
which set the default variants of the component. We set the defaultvariant
tooutline
and the defaultcolor
toslate
.
<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.
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.
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.
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.
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.
{
"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()
.
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!