Creating a component library

Here I will outline the setup I currently ended up with for my component library for the lolmath.net website: @lolmath/ui.

Build tooling

Initially, I went with tsup, because it gives good output, is lightweight and easy to configure.

However, I wanted to achieve an output that was not bundled; as it is easier for the consumer to see what is going on, and easier for a bundler to tree-shake; therefore I went with ModernJS Module.

Since then, I dropped the non-bundled output due to some limitations: for some bundlers it is problematic to consume non-bundled CSS files (which is understandable since it is not really part of the ESM spec as far as I am aware). ModernJS does not have to not bundle the CSS as far as I am aware.

In the future I might move to rslib, which seems to be the spiritual successor to ModernJS Module (they are both in the Bytedance ecosystem).

Headless component library

My premise was to build something on top of an existing headless UI library in order to do the heavy lifting. My library of choice was react aria components after also considering headless UI (which has a limited amount of components) and radix ui. Considering react aria components is backed and depended on by Adobe, the future of this library seemed more certain. Radix seems to have a more developer friendly API in some regards, for example with the asChild prop to make buttons into links. React Aria then has a seemingly more strict focus on accessibility as the name implies.

Tailwind

The first implementation was in Tailwind CSS. It did work quite fine mostly, but there are some considerations which eventually made me switch to CSS modules:

  • There are limitations on what you can do with TailwindCSS with regards to styling. For example, it is near-impossible to add a gradient border-image (very specific) in Tailwind classnames. You might also end up using extra elements to achieve your goal what could have been done with fewer elements using normal css.
  • You are restricting the consumer of your library: they must use tailwind in their local project, which also means they must use tailwind-compatible tooling. It must be noted that this is a limitation of my setup; shipping a css file precompiled by tailwind seems feasible as well, though that has some other considerations.

The setup of tailwind was such that the consumer of the library had to do some configuration in order to use it. Next to this, I created a tailwind plugin for the colors that were relevant to the UI library and included some classes for fonts.

The tailwind config for a consumer would look as follows:

import { lolmathui } from "@lolmath/ui/plugin";

module.exports = {
  content: [
    "./node_modules/@lolmath/ui/dist/**/*.{js,ts,jsx,tsx}",
  ],
  plugins: [
    lolmathui,
  ]
}

By doing this (letting the consumer's tailwind instance handle the CSS generation), you avoid duplicating classnames between the library and the consuming application, reducing bundle size and conflicts. But at the same time, you create a strong dependency on what is not a web standard, and you tie yourself (and your consumer) to a specific (major) version of tailwind, increasing maintenance burden.

Another point of interest of this Tailwind Styling setup, is customization. With tailwind, your classes will have the same specificity, and the user will have a hard time applying their own tailwind styles to override some aspects. For this, you can use tailwind variants. As an example, a divider component may look somewhat like this:

import { tv } from "../utilities/tv.js";

const divider = tv({
	base: "h-px grow border-0 bg-gradient-to-r from-gray-300 via-gray-400 to-gray-300",
});

export function Divider({ className }) {
	return (
		<hr
			className={divider({  className: className })}
		/>
	);
}

Here, the tv function is used to declare classNames, as well as merge in the user-specified className. By matter of last-applied-wins, the user class will always override any style of the same type by the library, which is normally preferable. This setup may require some configuration if you are extending the tailwind configuration (see the docs of tailwind-variants).

CSS Modules

I migrated to CSS modules from tailwind for my component library. This had some advantages:

  • Having a bundled CSS artifact makes it more portable.
  • There is no limiting factor for what is possible using CSS.

Regarding the last point, there is a feature in CSS called @layer, which can be used to manage specificity in a simple manner. The library author can declare all their styles within in layer. The user can then declare the order of the layers. Rules in the highest-priority layer trump rules in the layers with less priority. The user has to do a bit more setup (declaring the layer order), but it makes reasoning about specificity much easier. My current setup is something like this:

@layer mylibrary {
	.divider {
		height: 1px;
		flex-grow: 1;
		border: none;
		background-color: #e0e0e0;
	}
}

I am not yet sure if this is the optimal and most elegant solution. In each css module file, I have to remember to declare @layer, which is quite tedious. But the upside seems worth it for now.

The package.json

My package.json looks like this


{
	"name": "@lolmath/ui",
	"version": "4.0.0",
	"private": false,
	"type": "module",
	"files": ["dist"],
	"scripts": {
		"lint": "publint"
	},
	"dependencies": {
		"cva": "1.0.0-beta.1",
		"react-aria-components": "1.3.1"
	},
	"publishConfig": {
		"access": "public",
		"registry": "https://registry.npmjs.org/"
	},
	"devDependencies": {
		"@types/react": "^18.3.3",
		"@types/react-dom": "^18.3.0",
		"publint": "^0.2.9",
		"react": "^18.3.1",
		"react-dom": "^18.3.1"
	},
	"peerDependencies": {
		"react": ">=18",
		"react-dom": ">=18"
	},
	"exports": {
		".": {
			"import": {
				"types": "./dist/es/index.d.ts",
				"default": "./dist/es/index.js"
			},
			"require": {
				"types": "./dist/lib/index.d.ts",
				"default": "./dist/lib/index.js"
			}
		},
		"./css": {
			"import": "./dist/es/index.css",
			"require": "./dist/lib/index.css"
		},
		"./font/beaufort": {
			"import": "./dist/font/beaufort/beaufort.css",
			"require": "./dist/font/beaufort/beaufort.css"
		}
	}
}

I want to highlight a few things here:

publint

The publint dependency makes sure that anything that is published to npm is useable by the end-user: the exports are correct and point to existing files, among other lint rules.

exports

I only use the exports field, and not any "main", "types", "module", etc. fields. Modern tooling is able to use packages like this without problems.

peer/dev dependencies.

React should never be a direct dependency of your component library, as you will end up with multiple copies of react, which will cause problems. Make sure the version range is forgiving.

files

The files field makes sure that no unnecessary or accidental files get published to npm. package.json and readme.md in the root directory will be included regardless and as such do not have to be specified.

Documentation

For both development and documentation, I use storybook.

For further documentation, I use rspress, though at this point there is not much further documentation. In principle Storybook is fine on its own. It has good markdown capabilities. The only reason I use Rspress is for bringing the documentation of multiple related libraries together under 1 URL.

CI/CD

TODO: version management, creating prereleases.

Making consistent and composable components

TODO: making components that work properly together (e.g. form elements)

Fix Nooie Smart Plug + Google Home connectiviteit