how to create them, connect them and not be afraid of anything

Hello! I'm Lesha Kuzmin, the head of frontend at AGIMA. My colleagues and I decided to summarize our experience in preparing UI kits and make large and clear instructions for beginners. Firstly, it is convenient – we will give this article to our trainees and padawans. Secondly, we don’t mind – read, share your experience, ask questions in the comments.

Below we look at everything from the very beginning: from “why is this needed at all” to “how to use it on a real project.” And at the very end you will find a repository with code fragments that you can use in your work. An article for beginners and not only beginners Frontend developers. I thank my colleague for help in preparing it. Angelina Nikolaeva.

What is a UI Kit and why is it needed?

UI Kit (aka UI kit) is a set of components for the user interface, from which, like bricks, developers can later build an application interface. This concept is used in all frameworks.

The main advantage of a UI kit is that it allows you to quickly change interfaces in several applications at once, while using a single source of truth. This allows you to speed up development in the long term. And although preparing such a library may require additional time, it will definitely pay off in the future.

Here are other advantages of UI kits:

  • Sync with design, for example using Storybook.

  • Easier and faster testing of components, independent of the entire system.

  • Higher application security during updates – since everything is tested outside of them.

  • Higher speed of product development: if a component is used in several systems, it will need to be written once and then used everywhere.

Next, I will tell you what categories we can divide libraries into and what strategies should be chosen depending on this. And then we will talk about the practical use of UI kits and consider an example of a starter for a library.

The basis of the UI kit and its categories

Creating any component library requires a clear understanding of a number of factors:

  • what components to create;

  • how they will be used by the developer;

  • how supported the entire system of components will be;

  • how the connection between the library and the design system will be established;

  • whether the components will be connected optimally;

  • how to support the library if you need to support several design systems at the same time, etc.

And this is only part of the questions that a developer will certainly face. If you break down components into their components, there are two main parts: logic and styles.

Logics. Responsible for the functionality of the component: this includes, for example, event processing (clicks, focus, keyboard control, etc.), managing the component state (opening/closing, active/inactive, etc.), working with input data (filtering , sorting, etc.).

Styles This is the wrapper of the component. Styles visually reflect the design system and, accordingly, can change frequently and have great variability.

If you rely on these two components, you can divide all UI kits into two categories:

1. Library of smart “headless” components (Headless UI). There is no styling in such a UI kit; all components will include only functionality.

When to use these:

  • when you create a library for use in different projects that have different style requirements;

  • in large projects with several teams, since one team may be responsible for the work of the library, and the other for the design;

  • when you need to adapt a website for different platforms, you can separate the styles.

Advantages

Flaws

Can be easily integrated into an existing project.

Components can be used in different design systems.

Productive by separating logic and styles.

Difficult to support: require careful documentation at all stages.

The likelihood of a “mess” of components with different styles.

More time – more code.

2. A library with smart and beautiful components. In this type, all components have both logic and styles.

When to use these:

  • when the library will be used on several projects where a single design system is important;

  • when you need quick deployment of client projects;

  • when you don't need much flexibility in styling.

Advantages

Flaws

High stability of the library, since logic and styles will be tested immediately.

Speed ​​and ease of assembly of any project.

Consistency in the appearance of components.

If building a project using such a library is fast, then creating and changing components will take longer.

Limitations in customization options.

The components are heavier.

Possibility of conflicts in styles.

Strategies for writing UI kit components

There are two main approaches to developing UI kits:

  1. All-in approach.

Connecting a component with or without styles. Here, any component is an independent ready-made unit that already contains everything you need. Within this approach, two more subtypes can be distinguished:

  • Inline styles via Styled Components (maybe add just connecting styles inside the component). This method allows you to write styles directly in the component. At the same time, the styles are isolated, which reduces the possibility of conflicts between styles of different components.

import styled from 'styled-components';
const StyledComponent = styled.div`
  /* стили компонента */
`;
const Component = () => (
  <StyledComponent>
    {/* ... */}
  </StyledComponent>
);
export default Component;
  • Without adding styles (Headless). In this case, the components provide only the logic without the UI, which allows you to manage the styles yourself. To create such a library, you also need to familiarize yourself with the Compound component pattern, which will be discussed below.

const Component = () => { 
  return ( 
    <>
      {/* ... */}
    <> 
  ); 
}; 
export default Component;
  1. Dependency CSS & Bundle CSS approach.

The second big approach is when styles and components are connected separately. In this case, the styles and logic of the component are separated from each other.

Dependency CSS: This connection method improves modularity and allows styles to be loaded only when they are actually needed.

Bundle CSS involves connecting all styles at once and separately to the component. Essentially, in this case, all styles are combined into a common bundle and imported into the project root.

But when written, they are similar and styles are connected to the component as modules.

import styles from './component.module.css'

const Component = () => { 
  return ( 
    <div className={styles.div}>
      <h1 className={styles.title}>Title</h1>
      {/* ... */}
    </div> 
  ); 
}; 
export default Component;

Methods for connecting libraries

Now let's look at what these approaches would look like from a code perspective. We will give examples based on React/Next applications.

Let's start by looking at the options for how they are connected to the project in general. There are several ways:

  • git + https – a fairly simple option for connecting the library as a dependency. In this case, the default reference will be the latest commit on the branch in your repository. Of the minuses: you will need to install the library every time, since the commit is recorded in the Lock file and you will always receive the same version of the library. Well, we shouldn’t forget about full Push builds either. In this case, we cannot ignore the Dist folder with the assembly.

  • package registry – from our point of view, a more correct and familiar approach. This manipulation can be done within your Git repository through the CI/CD setup, information about which can be easily found in the documentation. Here's how, for example, you can do it in GitLab. But here the question arises, do you want to figure it out yourself or do you have a free DevOps who can help with this.

  • npm/yarnlink — a very hacky method, but it works and allows you to locally link your package as a dependency to the project, which will allow you to use it without publishing.

When using Headless UI, Styled Components, or when importing modular styles into a component file, everything is simple. We import the component and can use it immediately. In all cases, we provide the possibility of passing a class to customize styles from the outside.

import { Button, Typography } from "@frontend/ui-kit";

One limitation when using styles as a component dependency can be your framework. For example, NextJS prohibits this approach. You can read more about this in the documentation.

When using a full component library, we have two different paths:

  1. Import all styles into the application in one file. And then – the use of components in the necessary places. It all depends on your framework. In NextJS App Router, you need to import styles in the root file of the application. In our case it is _app.tsxwhich lies along the way ./src/pagesin one line:

import "@frontend/ui-kit/dist/style.css";

And then, as in the previous example, we can use similar imports in the necessary places:

import { Button, Typography } from "@frontend/ui-kit";

If you have a SPA, this is the simplest and most likely correct approach to use the components.

  1. Using SSR/SSG when we don't need the entire style bundle. Everything is also simple, but not so convenient. The first thing you need to do is write/find and connect a plugin that allows you to separate styles from a component without introducing them as a dependency. Next, let's see how this will be used in the code. I will say right away that nothing changes for importing components. Everything remains in one line:

import { Button, Typography } from "@frontend/ui-kit";

Now let's see how styles are connected:

import "@frontend/ui-kit/dist/Button/Button.css";
import "@frontend/ui-kit/dist/Typography/Typography.css";

Everything is tolerable here, but not as convenient as with a common bundle. Especially considering that this must be done for each component, and their number can be very large. But we save on the size of the bundle that the end user of the application will receive.

Strategy Comparison Chart

All-in

Dependency CSS

Bundle CSS

Headless UI

Styled Components

Ease of component development

+

+

+

Ease of use of components

+

+

+

Increasing project complexity

+

+

+

+

Ease of implementation of multiple design systems (?)

N/A

+

+/-

Suitable for small projects

+

+

+

Suitable for large projects

+

+

+

SSR friendly (?)

+

+

+/-

+

Minimum bundle size

+

+

+

Adding flexibility to the UI kit

Our practice proves that to write more flexible and functional libraries you need to pay attention to the use of the following stylistic and other approaches.

First and perhaps the most obvious is the use of variables in styles, both global and local. In this approach, your components will be based on the external environment, allowing you a lot of flexibility to control their appearance with minimal changes. For example, if you are making a White Label Library, then you should pay attention to this option. Of the minuses, it is worth noting the need to keep documentation about the variables used up to date.

Second, An equally interesting approach is the use of named layers for styling. In this case, changing the style of components is also quite simple, since you can add new styles through a new layer, and priority will be given to them. And if the developer does not specify a name for the new layer, then all styles will be placed in the default layer, which also has a higher priority for application. If for some reason you are not familiar with this approach, I recommend looking at the @layer documentation.

Third approach is to use patterns from your framework. For example, Compound Components – for displaying tabs, accordions, tables, etc. Not the best example, of course, from React Bootstrapbut to understand flexibility it will do:

import Carousel from 'react-bootstrap/Carousel';

function UncontrolledExample() {
 return (
   <Carousel>
     <Carousel.Item>
       <Carousel.Caption>
         <h3>Second slide label</h3>
         <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>
       </Carousel.Caption>
     </Carousel.Item>
     <Carousel.Item>
       <Carousel.Caption>
         <h3>Third slide label</h3>
         <p>
           Praesent commodo cursus magna, vel scelerisque nisl consectetur.
         </p>
       </Carousel.Caption>
     </Carousel.Item>
   </Carousel>
 );
}

export default UncontrolledExample;

Analysis of the selected strategy with implementation (example vite-rollup config)

Now let's look at how to make your own UI kit using a specific example. Let's look at one of our projects. This is an SPA application that has a user’s personal account, which means there should be no problems with long loading times. Of all the options listed above, the option with assembling styles into a single bundle is suitable here. According to the strategy, it is easier to get all the styles once, so as not to load chunks in the future.

We start development quite simply. We go to the Vite website and take the command to initialize an empty project in React + TypeScript. If other starters are needed, you can look at documentationhow to do it.

yarn create vite ui-kit –template react-ts

After executing the command, we will receive an almost empty starter project with which we will work.

The first thing I do is start with configuring package.json to restrict the versions of technologies used, and also to configure the package as a library.

"name": "@projectName/ui-kit",
"engines": {
 "node": ">=18.18.2",
 "yarn": ">=1.22.21"
},

For the “name” of the project we use the name space “name” space, in our case – “@projectName”. This is useful if you are going to create your own Package registry, from which you can then install it.

I also recommend specifying the “engines” block to secure the versions of the package manager and node and guarantee correct operation. Further, if CI/CD is not done by you yourself, this will also help avoid confusion. This is not necessary, but it is better to specify it once.

That's all from the basic settings. Next are specific to the library.

"peerDependencies": {
 "react": "^18.2.0",
 "react-dom": "^18.2.0"
},
"files": [
 "dist"
],
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
 "./dist/style.css": "./dist/style.css",
 ".": {
   "import": "./dist/index.js",
   "types": "./dist/index.d.ts"
 }
},
"sideEffects": [
 "**/*.css"
]

“peerDependencies” — allows you to specify the required versions of libraries that our component library will support.

“files” – the same folder where our build will be located.

“main” – a file that will contain a set of our components for import.

“types” — types for components, where without them.

“exports” — a block in which we indicate the necessary files for export – in the future they will be available for import in our application.

“sideEffects” — a parameter necessary for the ability to reset unnecessary dependencies or, as they say, “tree shaking” when used in the main project as a WebPack builder.

We've dealt with the first point. Then nothing stops us from writing our own components and assembling them into a bundle. This will already be enough to work with Headless UI Kit. But if we need styles for components, we will have to add a few more lines to the Vite configuration. First, let's install the necessary dependencies:

yarn add rollup vite-plugin-dts glob @vitejs/plugin-react

After that we will replace the contents “vite.config.ts” to the following, after which we will analyze it point by point:

import { defineConfig } from 'vite'
import dts from "vite-plugin-dts";
import { extname, relative, resolve } from 'path'
import { fileURLToPath } from 'node:url'
import { glob } from 'glob'
import react from "@vitejs/plugin-react";

const entries = Object.fromEntries(
 glob.sync('src/components/**/*.{ts,tsx}').map(file => [
  relative(
      'src/components',
      file.slice(0, file.length - extname(file).length)
    ),
    fileURLToPath(new URL(file, import.meta.url))
  ])
)

const outputBase = {
 globals: {
   "react": "React",
   "react-dom": "ReactDOM",
   "react/jsx-runtime": "jsxRuntime",
   "classnames/bind": "cn",
   "classnames": "classnames"
 }
}

// https://vitejs.dev/config/
export default defineConfig({
 plugins: [
   react(),
   dts({
     insertTypesEntry: true,
   }),
 ],
 define: {
   'process.env': {}
 },
 build: {
   emptyOutDir: true,
   outDir: "./dist",
   lib: {
     name: "uikit",
     entry: resolve(__dirname, "src/components/index.ts"),
   },
   ssr: true,
   copyPublicDir: false,
   // https://vitejs.dev/config/build-options.html#build-rollupoptions
   rollupOptions: {
     external: ["react", "react-dom", "styled-components", "classnames"],
     input: entries,
     output:
     [
       {
         ...outputBase,
         exports: "named",
         format: "cjs",
         esModule: true
       },
       {
         ...outputBase,
         exports: "named",
         format: "esm",
         interop: "esModule",
       },
   ],
     plugins: [
     ],
   }
 },
})

entries – this is essentially a list of paths to our components, which we will use later for assembly. It allows us to replicate the components folder structure in our library's Dist folder.

outputBase — to assemble different formats, we will use the settings, and some of them are repeated, so we will remove them for more convenient controllability.

Now let's look at the config itself.

plugins — plugins that we connected for assembly. We have two of them in our database: the react itself — the first, the second — for generating types.

define – allows you to define variables that we can work with during assembly, since there may be a need to use environment variables. Let's announce them in this block.

build – one of the most interesting sections of the config, which is worth talking about separately, but it also contains obvious things.

emptyOutDir — is responsible for cleaning the build directory.

outDir: — is responsible for the name of the folder for the build.

lib – allows you to configure the build mode as a library.

name — name of the library.

entry — name of the input file for the assembly.

ssr – is responsible for the assembly capability, which will be oriented for SSR.

copyPublicDir — is responsible for the need to copy the Public folder into the assembly, for example, if we need some pictures.

And finally we got to something useful:

rollupOptions – responsible for configuring the collector rollup.

external — allows you to define external dependencies for the library.

input — allows you to specify a file or list of files to build the library. If we specify a list of files, then the Dist folder will have a repeating folder structure with components. This is precisely why we previously formed an array with file names and defined it as entries.

output — an array of format settings for the final bundle. In our case, we use two formats: CJS and ESM. More details about formats and their settings can be found in documentation.

plugins — allows you to connect plugins for the collector, in our case we do not use anything.

So, at this point we are ready to write our components and assemble a library from them. There are actually a ton of approaches to writing components, so choose the one you like. We chose module.css + clsx + sass for more flexible writing and styling.

In terms of the folder structure, we made everything quite simple, added components, where our components lie further in a flat structure.

It is important that in the root of the folder with components we need to add the index.ts file, to which we will add our components, and it in turn will also be the entry point for the assembler.

A small example of the contents of this file:

import { Checkbox } from "./checkbox/checkbox";
import { Form } from "./form/form";
import { Radio } from "./radio/radio";
import Input from "./input/input";
import SearchInput from "./search-input/search-input";
import PasswordInput from "./password-input/password-input";

export {
 Checkbox,
 Form,
 Radio,
 Input,
 SearchInput,
 PasswordInput,
};

Let's look at an example block “scripts” in our project:

"scripts": {
 "dev": "vite",
 "build": "tsc && vite build",
 "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
 "format": "prettier --write ./src",
 "prepare": "husky",
 "bump": "npm version patch -m \"UI Kit version updated to v%s\"",
 "bump:minor": "npm version minor -m \"UI Kit version updated to v%s\"",
 "bump:major": "npm version major -m \"UI Kit version updated to v%s\""
},

Most often we will use two commands. First – yarn bump, which changes the version of our library via NPM. Second – buildwhich collects the library bundle.

If we are dealing with a package registry, we set it up in CI/CD, but don’t forget about husky, who also has to make a build so that we don't push a broken version.

dev – nothing new, this is a development mode.

It would also be a good idea to use linters and formatters. In addition, we can wind up not only the fixed version, but also the minor and major versions “bump:minor” and “bump:major” respectively.

An example of a UI starter kit for React/Next JS can be found in the repository.

How to use all this in a real project

In our practice, there were several projects when we built our own CMS systems. As a rule, in this case we use the UI Kit + Headless CMS approach or write separate admin panels for the API, which allow us to quickly assemble the user interface from a predictable list of components. This at least reduces the chances of getting an inconsistent interface. How we connect UI Kit and Headless CMS, described in a separate article.

Let's look at another example from personal experience. The customer company was represented in several countries, at least 20 regions. At some point, the business had the task of launching experiments on registering users on the platform every two weeks, with a new interface.

This is difficult to do when you have one monolithic application. Therefore, we came to the option of dividing the application into several parts, one of which is responsible for the components – essentially, a UI kit, the other for business logic and interaction with the backend, and the last one is a starter template for quickly being able to assemble a bundle of components and utilities of the previous two.

Accordingly, if changes were necessary, in 99% of cases we only needed to add new versions of components and build a new application. This is done quickly, in one two-week sprint we managed to do everything, test and deploy.

Most likely, the “mitigating circumstance” was compliance with the design and, if possible, maintaining recognition, but no one is stopping you from writing blank components, on which you can just as quickly sketch out styles and get a ready-made solution.

If we go further with this strategy, we can assign a developer to the design team who can prepare components for external development teams. This will not only help speed up the process, but will also strengthen the team’s independence from external factors and control over the quality of development of presentation components and compliance with a unified design system.

In conclusion, I would like to note that you should not be afraid to write your own UI kit, since in most cases all companies have a unique design, and the ability to create your own library will be a big plus. True, this will require some effort on your part.

Another important factor is time. It will have to be spent on detailed development of the components in order to take into account all the necessary behavior and interaction options. As far as I remember, we spent about two weeks of development to split the application into several parts. But once you start and consolidate the rules, stop writing components within the project and immediately put them in the UI kit, things will go much faster.

The main thing is to remember that you need to gradually fill the library with components, and not demand a quick transfer of everything at once. We take it and gradually refactor it, without rushing anywhere to get a ready-made set.

If you have any questions, ask them in the comments. We will try to answer everything. Thank you for your attention!

What else to read

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *