Building Bennie-UI: a year of shipping a small design system
This is the story of why I built Bennie-UI. I wanted the power of a monorepo, with every UI component in one place, but reusable across all my projects, without the hassle of publishing packages to the npm registry, and with a lot less verbosity than what I was used to.
Historical context: CSS methodologies vs tokenized design systems
I come from a background of CSS methodologies like SMACSS and BEM. At the time they were genuinely useful: they gave a team one shared way to name and organise rules, styles and animations, and they kept specificity fights under control.
But they came with a trade-off. The CSS only ever grew, sprint after sprint, and going back to clean up classes nobody used anymore was a really hard task. Nobody wanted to be the one who deleted a class and broke a page they'd never heard of.
That's when a lot of people moved to CSS-in-JS and PostCSS compilers. Getting them to work efficiently through webpack plugins was, in my opinion, painful, and I suspect that's part of why Next.js took off: it made that setup someone else's problem.
I never liked CSS-in-JS. Most of the time it just bloated the JavaScript, which makes the app slower in the browser, and it cost us something else: long-term caching. If your CSS barely changes between releases, making every user re-download it with each new bundle is wasteful.
Then I started playing with Tailwind, and it just made sense. Ship only the classes your project uses, so there's no duplication. No custom CSS to write. Fewer specificity issues across the team. A ton of utilities out of the box. And the stylesheet caches well. There was only one problem: every className turned into three, five, ten lines of classes, with every style the team had stacked on it.
So I started wondering: what if the styling lived outside the component and came in as props? The component would only care about what it does, and how it looks would be passed in. I began building my primitive components by hand.
Getting the first set of attributes in place was a lot of work: colours for text, background and borders, margin, padding, size, opacity, height, width, position, flex, CSS grid, and so on. Then one day I found Panda CSS, and that was it. Type-safe style objects, compiled to static atomic CSS at build time, no runtime cost, and caching that works. It solved every problem I had been fighting, and from then on I could just focus on building the components, and they came out the way I wanted.
What I mean about layouts
Without getting into specifics, in the design system I was working with at the time it was very common to see components like this:
<Viewport>
<ViewSmall>
{/* …the mobile version */}
</ViewSmall>
<ViewMedium>
{/* …the tablet version */}
</ViewMedium>
<ViewLarge>
{/* …the desktop version */}
</ViewLarge>
</Viewport>Or nested layout components like this:
<Flex>
<FlexItem>
<FlexDirection direction="end">
<Padding>
{/* …the content you actually came for */}
</Padding>
</FlexDirection>
</FlexItem>
</Flex>That was driving me insane. Now picture flex and grid components inside those viewport components, and the pages getting bigger. You scroll and scroll through View, Flex, Grid… wondering when you'll finally reach the content you actually wanted to see.
What I wanted instead is one element that describes how it behaves at every size, right where it is. Here is a flex section that stacks its children on phones and lines them up in a row from tablets up, with alignment and spacing that change with the screen:
<Section
className={css({
display: "flex",
flexDirection: { base: "column", md: "row" },
alignItems: { base: "stretch", md: "center" },
justifyContent: "space-between",
gap: { base: "3", md: "6" },
})}
>
{/* …content */}
</Section>And the same idea with CSS grid: one column on phones, two on tablets and three on desktops, with spacing that grows with the screen:
<Section
className={css({
display: "grid",
gridTemplateColumns: { base: "1fr", md: "repeat(2, 1fr)", lg: "repeat(3, 1fr)" },
gap: { base: "4", lg: "8" },
paddingX: { base: "4", md: "8" },
})}
>
{/* …content */}
</Section>No wrappers, no duplicated markup per breakpoint, and the content is right there in the file.
Bun workspaces instead of npm packages
I also hated managing and publishing npm packages: version bumps, publish steps, registry tokens, and waiting on a release just to try a one-line fix in an app. Or worse, realizing you made a mistake and having to patch the library again, and again, and again.
On one team we had a pipeline that published "snapshot" versions of our components just to get around that. Snapshots weren't immutable, so the same version could mean different code from one day to the next. That's what pushed me away from publishing and toward monorepos. Bennie-UI isn't published anywhere. Each project pulls it in as a git submodule:
git submodule add https://gitlab.com/bennie-ui/bennie-ui.git packages/bennie-ui
and lists its packages as Bun workspaces:
{
"workspaces": [
"packages/bennie-ui/libraries/*",
"packages/bennie-ui/components/primitives/*",
"packages/bennie-ui/components/composites/*",
"packages/bennie-ui/components/composites/forms/*"
],
"dependencies": {
"@bennie-ui/button": "workspace:*",
"@bennie-ui/visualization": "workspace:*"
}
}Every component is its own workspace package (@bennie-ui/button, @bennie-ui/forms, @bennie-ui/visualization…), so an app only depends on what it imports. Updating Bennie-UI in a project is moving the submodule to a new commit. Fixing a component means changing it in place, seeing it in the app right away, and pushing it back for every other project.
The components themselves only carry very basic styling. Panda scans the components and the app, and compiles the CSS from the style properties they actually use, so the PostCSS step stays very efficient, the stylesheet stays small, and the components stay clean.
Building on the building blocks
Now that the basic building blocks and their properties are defined, I can build components the way I want them and focus on the functionality that actually matters.
The new table component and the modals I just built are a step in the right direction:
<Table<User>
result={page.result}
result_info={page.result_info}
options={options}
onUpdate={(next) => setParams(urlFromOptions(next))}
actions={actions}
>
<TableToolbar placeholder="Search name or email" />
<TableFilters>
<TableFilter id="status" label="Status" options={statuses} allLabel="All statuses" />
</TableFilters>
<TableSort options={sorts} />
<TableCell name="email" header="Email" />
<TableCell name="name" header="Name">{(u) => `${u.firstname} ${u.lastname}`}</TableCell>
<TableFooter pageSizes={[10, 25, 50]} />
</Table>This is something like my third or fourth dynamic table, and I've put everything I've learned from the previous ones into it: search, filters and sorting in one bar, browser or server data, row actions, modals and sidebars that live in the URL, and a layout that turns into cards on phones. It's probably still not done, but it has everything I need right now. I'll write a separate post about how it was built.
There are more interesting components coming. In the meantime you can play with the table, the modal and the sidebar at bennie-ui.com, browse every variation in Storybook, or read the code on GitLab. Feedback welcome.