What is a design system?
Whenever we're talking about a group of components e.g buttons, inputs, icons that share similarities in the design e.g colors, fonts - we're talking about a design system.
Whenever we're talking about font families line-heights, or font sizes for given headings and paragraphs (a.k.a typography) - we're talking about a design system.
To summarize, a design system is a set of guidelines and components that together create a coherent application.
Advantages of a design system
-
01
Consistency: By introducing visual and interactive elements with a similar color scheme and fonts, a design system ensures that a given application looks and behaves consistently across a whole app or even multiple apps.
-
02
Implement once, reuse everywhere: It streamlines the design and development process by providing reusable components and patterns, reducing the need to reinvent solutions for common design challenges.
-
03
Scalability: As applications evolve and new features are added, a design system helps maintain coherence and adapt to changes without compromising the overall user experience.
What makes a good implementation of a design system component?
Now that we know what a design system is and its benefits, let's consider for a second what we, front-end developers, need to keep in mind when implementing a component from the design system.
Ease of use - every component should have a simple and intuitive API that builds on top of or extends native HTML element attributes. Here are a couple of examples:
-
every prop that corresponds to an event handler should start with the
on
prefixonClick
onInputChange
onSelect
since it fits react elements event handlersonClick
onMouseEnter
onMouseLeave
; -
every prop that is a boolean should be in the past participle form, e.g.,
disabled
,focused
,selected
, since it fits native HTML attributes, e.g buttons or inputs;
These rules are not set in stone and may differ from project to project. However, it's important to decide on one way, before implementation and document it as a guideline for other developers to be aware and follow.
Extensibility - there can be places within the application where the same component e.g. inputs, has a fixed width of 200px, but should take a full container width in other places. So, each component should have some form of flexibility to also cover some out of ordinary scenarios.
Tools to implement a design system
At Bitnoise, we've designed and implemented many unique and beautiful design systems in React-based applications. Thus, we have a selection of proven tools that help us implement quickly and painlessly.
Keep in mind that this tool selection is mostly based on our team’s preferences. Selected tools may differ based on your project demands and team preferences.
These are the tools we commonly use:
-
clsx - for managing components classes.
-
tailwind-merge—to ensure that tailwind classes do not contradict one another.
-
class-variance-authority—for handling complex component styling that differs based on provided props. For example, a button can have a primary, secondary, or ghost variant.
-
react-aria-components - to ensure our components are accessible and meet WCAG requirements.
-
storybook - for having all components list out and documented. It helps to avoid duplicated components and to introduce a new developer into a project.
Implementing a component
In this section I will guide you through my process of implementing the components from design systems.
1. Looking at a provided design system
First things first, we need to examine the beautiful design system provided by a UI designer (for simplicity, let's focus on a single part of the design system - typography).
Here are a couple of notes I took by looking at the design:
-
we have 6 headings and 2 body texts,
-
font sizes, font weights and line heights are as follows:
-
Heading 1 - 92px, 700, 120%
-
Heading 2 - 73px, 600, 120%
-
Heading 3 - 59px, 600, 120%
-
Heading 4 - 48px, 400, 120%
-
Heading 5 - 38px, 400, 120%
-
Heading 6 - 30px, 400, 120%
-
paragraph 1 - 24px, 400, 125%
-
paragraph 2 - 16px, 400, 125%
-
small text - Manrope, 12px, 500, 125%
-
2. Creating utils and utility types
Before implementing the component itself, we will add one utility function and one utility type that will help us in implementing this and other components.
// utils/cn.ts
import { ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
These utils help keep already long tailwind classes clean and noncontradictory to each other.
If you're not using tailwind, you don't need to have this util... duh.
// utils/types.ts
export type PropsWithClassName<T> = T & { className?: string }
Similar to the built-in react utility type PropsWithChildren
we're implementing a generic utility type PropsWithClassName
to ensure that our components will also accept the className prop and so that we don't need to add them manually in each and every component props.
3. Implementing the component
Now it's time to get our hands dirty and actually start to implement the component itself.
- Keeping in mind ease of use rule described above, we can map Heading 1, ..., Heading 6 to h1
to h6
html tags, which is convenient. Paragraphs 1 and 2 should be a p
tag, and small text can be a span
.
// components/typography.tsx
import { PropsWithChildren } from "react";
// this will map to 'heading-1' | 'heading-2' | 'heading-3' | 'heading-4' | 'heading-5' | 'heading-6' | 'paragraph-1' | 'paragraph-2' | 'small-text'
type TypographyTagSize = 1 | 2 | 3 | 4 | 5 | 6;
type TypographyTag = `heading-${TypographyTagSize}` | `paragraph-${Extract<TypographyTagSize, 1 | 2>}` | "small-text"
Now that the basis of the components were implemented, we should add styles for a given tag to match the design system.
// I've change px values from the design to rem using https://codebeautify.org/rem-to-px-converter
const typographyStyles = cva("antialiased", {
variants: {
tag: {
"heading-1": "text-[5.75rem] leading-[1.2] font-bold",
"heading-2": "text-[4.5625rem] leading-[1.2] font-semibold",
"heading-3": "text-[3.6875rem] leading-[1.2] font-semibold",
"heading-4": "text-[3rem] leading-[1.2] font-semibold",
"heading-5": "text-[2.375rem] leading-[1.2]",
"heading-6": "text-[1.875rem] leading-[1.2]",
"paragraph-1": "text-[1.5rem] leading-tight",
"paragraph-2": "text-[1rem] leading-tight",
"small-text": "text-[0.75rem] leading-tight font-medium",
},
},
defaultVariants: {
tag: "paragraph-2",
},
});
And then we can apply the styles to the component.
return (
<TagComponent className={typographyStyles({ tag })} {...rest}>
{children}
</TagComponent>
);
So far, so good, but what if we want to have a different color or we want to head 1 to be semibold instead of bold in some places in the application? The component should allow updating styles when needed.
export function Typography({
tag = "paragraph-2",
children,
className,
...rest
}: PropsWithClassName<PropsWithChildren<Props>>): JSX.Element {
const TagComponent = typographyTagMap[tag];
return (
<TagComponent className={cn(typographyStyles({ tag }), className)} {...rest}>
{children}
</TagComponent>
);
}
Now overwriting basic styles is possible with new classes provided in the Parent component by using className
prop.
4. Using the component in the application
<main className="flex h-screen w-screen flex-col gap-y-6 p-24">
<Typography tag="heading-1">Hello world</Typography>
<Typography tag="heading-2">Hello world</Typography>
<Typography tag="heading-3">Hello world</Typography>
<Typography tag="heading-4">Hello world</Typography>
<Typography tag="heading-5">Hello world</Typography>
<Typography tag="heading-6">Hello world</Typography>
<Typography tag="paragraph-1">Hello world</Typography>
<Typography tag="paragraph-2">Hello world</Typography>
<Typography className="text-blue-700" tag="small-text">Hello world</Typography>
</main>
Here's how it looks in the browser:
Whole component code:
import { PropsWithChildren } from "react";
import { cva } from "class-variance-authority";
import { cn, type PropsWithClassName } from "@/lib/utils";
type TypographyTagSize = 1 | 2 | 3 | 4 | 5 | 6;
type TypographyTag = `heading-${TypographyTagSize}` | `paragraph-${Extract<TypographyTagSize, 1 | 2>}` | "small-text";
const typographyTagMap: Record<
TypographyTag,
keyof Pick<JSX.IntrinsicElements, "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p" | "span">
> = {
"heading-1": "h1",
"heading-2": "h2",
"heading-3": "h3",
"heading-4": "h4",
"heading-5": "h5",
"heading-6": "h6",
"paragraph-1": "p",
"paragraph-2": "p",
"small-text": "span",
};
const typographyStyles = cva("antialiased", {
variants: {
tag: {
"heading-1": "text-[5.75rem] leading-[1.2] font-bold",
"heading-2": "text-[4.5625rem] leading-[1.2] font-semibold",
"heading-3": "text-[3.6875rem] leading-[1.2] font-semibold",
"heading-4": "text-[3rem] leading-[1.2] font-semibold",
"heading-5": "text-[2.375rem] leading-[1.2]",
"heading-6": "text-[1.875rem] leading-[1.2]",
"paragraph-1": "text-[1.5rem] leading-tight",
"paragraph-2": "text-[1rem] leading-tight",
"small-text": "text-[0.75rem] leading-tight font-medium",
},
},
defaultVariants: {
tag: "paragraph-2",
},
});
type Props = React.HTMLAttributes<HTMLHeadingElement | HTMLParagraphElement | HTMLSpanElement> & {
tag?: TypographyTag;
};
export function Typography({
tag = "paragraph-2",
children,
className,
...rest
}: PropsWithClassName<PropsWithChildren<Props>>): JSX.Element {
const TagComponent = typographyTagMap[tag];
return (
<TagComponent className={cn(typographyStyles({ tag }), className)} {...rest}>
{children}
</TagComponent>
);
}
Summary
In this blog post, we've created a very intuitive and simple-to-use component that will handle typography in the application. If the styling of any given tag needs to be updated, it will only require a change in one place instead of looking for specific tags across multiple files.
I hope that walking you through my work process, from looking at a design system to implementing a React component will help you think differently about building components, ensuring they are both intuitive and highly reusable.