Here I will outline the setup I currently ended up with for my component library for the lolmath.net website: @lolmath/ui.
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).
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.
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:
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).
I migrated to CSS modules from tailwind for my component library. This had some advantages:
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.
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:
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.
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.
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.
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.
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.
TODO: version management, creating prereleases.
TODO: making components that work properly together (e.g. form elements)