Why I Built Lumi UI
Lumi UI is an open-source component library built on Base UI and Tailwind CSS. You can explore it at lumiui.dev or browse the source on GitHub.
But this post isn't really about the library. It's about the problem I kept running into that made me build it.
When Base UI launched, a few component libraries appeared quickly. I looked at them, checked their code, how they designed the API, how they handled styling. They were solid. But they all looked like shadcn with a different engine underneath. The API was almost identical: wrap the primitive, add some Tailwind classes, re-export it.
That's not a criticism. It's the obvious move. shadcn's API is well-understood, and developers expect it.
But as I dug deeper into Base UI's docs and advanced examples, I kept finding things that pattern couldn't reach. Nested dialog stacking. The viewport layer as a composable primitive. Transitions that reverse mid-animation instead of snapping. These weren't edge cases — they were the kind of details that make an interface feel considered instead of assembled.
I decided to build my own. Not to do something different for its own sake, but because I wanted a library that actually used what Base UI made possible.
Every project starts the same way. You pick a component library, get productive fast, and everything feels clean. Then the app grows.
A feature needs a dialog that slides up from the bottom on mobile but centers on desktop. Another needs a select where the checkmark goes on the left instead of the right. A third needs a dropdown item that's a link, not a button.
You look at your component and face a choice.
The two bad options
Option 1: Copy the component.
Create dialog-sheet.tsx next to dialog.tsx. Copy the source, tweak the layout. It works. But now you have two files. When you fix a bug in one, you forget the other. When you update styles, they drift apart. Six months later nobody knows which one is "the real one."
Option 2: Modify the source.
Add a layout prop to DialogContent. Handle the new case with a conditional. This works too, until existing usages break because the default behavior shifted, or the component accumulates enough if branches that nobody wants to touch it.
Neither is wrong, exactly. The problem is the assumption underneath: one component file should do one thing. Once you need more than one variation, that assumption breaks.
I kept running into this. And I started wondering if the framing was off.
The component file as a system
What if @/components/ui/dialog.tsx wasn't a single component, but the complete system for dialogs in your app?
One file owns everything: raw primitives for when you need full control, and composites for the 80% case. Both live together, both export from the same import path. Need a new layout? Add it as a cva variant, typed, documented, in the same file. Need something the composite can't do? Reach for the primitives and compose from scratch.
No copies. No drift. One place to look.
The morphing dialog is the clearest example of why this matters. It's a Framer Motion shared layout animation: a card in a grid expands into a full-screen dialog, the image and title morphing through space. To make that work, the motion.div carrying layoutId has to be the dialog popup element. Not a wrapper around it. Not a child of it.
If DialogPopup is buried inside DialogContent, there's no seam to reach. The only solution is to expose DialogPopup as a primitive, and once you do that, DialogContent becomes one valid assembly, not the only one.
That's how most of the primitives got exposed. Not because I decided it was good architecture, but because a specific component wouldn't work otherwise.
Code above available at MorphingDialog
The next post gets into how this plays out in practice: the file structure, the variants pattern, and how composites and primitives stay in sync without getting in each other's way.
Styles drift too
Structure is only half the problem. If every developer assembles components differently, styles drift even when the architecture doesn't.
So I made styles a contract: global utilities that are the only path to shared behavior. When anyone needs to animate a popup, they write animate-popup. When a list item needs a highlight, they write highlight-on-active. The implementation lives once.
These use CSS Transitions instead of CSS Animations on purpose. Transitions can reverse mid-way. Open a dropdown and immediately close it — it reverses cleanly instead of snapping through the open animation first. Users don't notice when it's right, but they do notice when it's wrong.
Where AI enters
I was already using AI agents heavily when I built this. And I noticed something: when the codebase has inconsistent patterns, agents pick one arbitrarily. They become a source of drift rather than a consistent contributor.
Lumi's architecture removes that ambiguity. Flat named exports, composites as executable documentation, global utilities as the only path to shared styles. The agent reads the file and learns the pattern. When it adds a new component, it looks like it was written by the same hand as everything else.
If you're using AI agents to write code, the architecture decisions above aren't incidental — they're the whole point.
What Lumi is (and isn't)
Lumi isn't a criticism of other component libraries. These are good tools with different goals. This is just a different set of tradeoffs.
You own the code — components copy into your project via the CLI, no runtime Lumi dependency. Behavior comes from Base UI, which handles accessibility, focus management, and keyboard navigation without configuration. Each component file is the complete system for that domain: primitives when you need control, composites when you need speed, global utilities to keep everything consistent.
It's a specific set of bets. If they match how you think about building, give it a look.
In this series:
- Why I built Lumi UI — the problem and the idea behind it
- One file, one source of truth — the dual-layer architecture in practice
- Designing for AI agents — how the architecture becomes an instruction set
- The details that matter — animations, hit areas, and type safety