
2026
Design system
A simple design system implemented with Base UI.Table of Contents
Introduction
My refreshed design system, built with CSS Modules and Base UI.
It's finally here. My personal design system, now open sourced. Documentation is still a work in progress, and it's not well-tested (yet). However, I've learned a lot building natmfat
(my previous personal design system), and I've refined it further here with @koide-labs/ui
.
It even has a fully featured Storybook!
What have I learned?
How to write better code. Probably.
Radix primitives are dead
Unfortunately, I just don't see a whole lot of activity behind Radix. Yes, Radix still has a lot of momentum and traction - in part, I suspect, because shadcn/ui keeps building on it. However, the library hasn't received many updates despite a growing numbr of issues. It's simply not as robust and not well maintained. Additionally, there are some architectural differences between Radix and Base UI that make some things impossible - such as enter and exit transitions.
Base UI is committed to making a "future-proof foundation", and even has a dedicated full-time team. I'm optimistic Base UI won't face the same (immediate) fate as Radix. Naturally, building my UI library on the corpse of another was a very unappealing prospect. Since evaluating my options, I decided to rewrite the entirety of natmfat
to use Base UI, and rebranded it as @koide-labs/ui
.
CSS Modules are still better
In my previous write up on my portfolio, I outlined a somewhat confusing scheme of using Class Variance Authority (CVA) to define variants for components, CSS layers to override already defined styles, and then heavily leaning on composition for everything - including applying styles to components. When I had the opportunity to rewrite everything from the ground up, I realized that all of that is basically useless and just creates more work for little benefit.
I immediately ditched it all and used CSS Modules for everything.
CSS Modules support dynamic class names, so we can just reuse any variants passed as props. I'm sure there's some benefit to using CVA, but it just feels overengineered and too complicated for a relatively simple problem. I ended up using a Typescript plugin for CSS Modules for "end to end" typesafety across the styles and props defined in TypeScript. Unfortunately, while this approach is not without its own drawbacks (plugins are not supported at build time, so it's just for IntelliSense), it's good enough for my purposes.
import styles from "./index.module.css";
type Variant = "sm" | "md" | "lg";
export function Component({ variant = "md" }: { variant: Variant }) {
return <div className={styles[`variant_${props.variant}`]}></div>
}
const variants = cva(styles.base, {
variants: {
size: {
sm: styles["variant_sm"],
md: styles["variant_md"],
lg: styles["variant_lg"]
}
},
defaultVariants: {
size: "md"
}
})
By definition, CSS Modules are isolated and the last class has maximum precedence, so layers are practically useless. I removed them without too much effort.
Composition, when used too frequently, sucks. Previously, I used components to apply styles:
<Interactive>
<Colorway color="primary" variant="fill-outline">
<Loading>
<button></button>
</Loading>
</Colorway>
</Interactive>
But this is verbose and can easily cause conflicts or confusion depending on the order in which the components are nested. Does Interactive
or Colorway
take precedence? Frankly, I made the library and it wasn't even clear to me. I decided to centralize everything into a single component - the View
, letting CSS Modules handle precedence. The styles should be flattened and simply cascade, not be put together with some weird nested hack job.
<View render={<button />} interactive="primary_fill-outline" loading></View>
Composition sucks
Yes, only in some cases. When you need unstyled primitive components, composition is amazing because it's infinitely more customizable than a long list of props
that would be required to describe every single variant.
However, in making an opinionated component library with design constraints, composition that's used often doesn't make that much sense.
- It's unwieldly to use and difficult to read because it's mind numbingly repetitive
- It allows behaviors that shouldn't be possible in the first place.
For example, you shouldn't be writing this as a consumer of a UI library:
<ContextMenuRoot>
<ContextMenuTrigger></ContextMenuTrigger>
<ContextMenuPortal>
<ContextMenuPositioner>
<ContextMenuPopup>
<ContextMenuItem />
</ContextMenuPopup>
</ContextMenuPositioner>
</ContextMenuPortal>
</ContextMenuRoot>
It's simply too verbose. Perhaps in an era where code is cheap, this actually makes sense because the LLM will have a greater understanding of the context and purpose behind each component. But as a human, typing and reading that sucks.
Additionally, using props allows you to easily enforce certain behaviors, such as an icon can only go on the left of a Button
or a Dialog
must have a title. It would not be possible to achieve this with composition without traversing React nodes at runtime or some kind of compiler mechanism.
In this way, less exported components and centralizing logic into props is actually better.
Rewriting presents opportunity
Tearing it all down and starting anew is honestly a terrible idea, and not a decision you can make for most complex software projects. However, rewriting everything from the ground up is somewhat appealing because it enables you to make major stylistic (such as component names) and architectural (such as using Base UI instead of Radix) changes.
Additionally, you've most likely identified several pain points by using the old project, some of which cannot be fixed without a major overhaul. Sometimes, issues just accumulate because of technically working "temporary" measures and TODOs that get strapped together and then promptly forgotten. Rewriting presents a great opportunity to get things right the first time.
You can easily enforce a higher standard of code quality right from the beginning. For @koide-labs/ui
, that meant linting (Eslint and Stylelint) and precommit hooks.
Challenges
Rewriting my old UI library was mainly just tedious.
Base UI intentionally made the API very similar to Radix, which already was very consistent and easy to understand. So the underlying framework wasn't that hard to port over. I did make some design choices that did require refactoring across the board (namely using View
instead of the combination of Interactive
and Colorway
), but that wasn't particularly difficult either.
cmdk
I did recall a pretty significant challenge though - integrating cmdk
. cmdk
is an unstyled command menu (you can try it out here on my website by pressing cmd + j). Importantly, it adds a lot of random stuff to the DOM to make the component easier to style. For example, it adds attributes like cmdk-heading
or data-disabled
. This is all well and good, but it turns out that cmdk
and Base UI differ slightly in their behavior. Base UI might add a data-disabled
attribute, but it will also remove it if the element is not disabled. I created all of my styles under the impression that data-disabled
was only added when the element was actually disabled.
.interactive:not([disabled], [data-disabled]) {
}
Turns out cmdk
always has the data-disabled
added, but it is set to either true
or false
. This was, quite frankly, annoying. I could not use [data-disabled="true"]
, because Base UI only adds the attribute but does not give it a value. I was scratching my head on how to easily resolve this.
So I ended up just forking the entire library and installing it from Github with my changes applied. Easy.
Icons
Previously, I had a custom build script that would traverse a directory of SVGs (sourced from Remix Icon) and then generate a corresponding React component. This approach was inherently very fragile and tedious to update (I would have to manually include that directory and then run the script). I realized I could just install remixicon
directly and then traverse the JSON they conveniently export, rather than the local file system. This way, updating the remixicon
package would also upgrade the generated icons.
But this also felt wrong. SVGs as components are generally kind of bad for performance, because they just bloat up the DOM (especially if you reuse icons). I ultimately decided to use an SVG sprite instead, which remixicon
also very kindly provides. Unfortunately, this sent me down a deep rabbit hole.
Let's say you import the sprite with Vite:
import spriteMap from "./path.svg";
When you build this in library mode, Vite will inline the asset as a big nasty string.
I initially thought this would be okay. Just let the bundler handle everything. Turns out, that's no good. You can't load from data:image/svg+xml
because...
unsafe attempt to load URL <URL> from frame with URL <URL>.
Domains, protocols and ports must match. can't load data:image...
What does that even mean? I have no idea. Probably some security policy.
A potential solution occured to me - I'm using vite-plugin-lib-inject-css, which preserves the CSS import statements. So why not just preserve the image import statement, thereby bypassing the entire "you can't load from this url" problem? However, a quick read through the plugin source code quickly revealed that their approach was only applicable to CSS, not any generic file.
I was banging my head against the wall for a while. And then it came to me: just make remixicon
external. That way, Vite literally can't transform the input statement. Yes, this forces users to also install remixicon
along with the actual library, @koide-labs/ui
. But specifying it as a peer dependency pretty much fixes this problem.
Pretty much all of the problems I was having where the UI library would behave weirdly when imported into another project was completely resolved by making what should be external, external.