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 exactly 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.

src/components/ui/dialog.tsx
// Primitives — raw building blocks, zero opinions
export { Dialog, DialogTrigger, DialogPortal, DialogBackdrop, DialogViewport, DialogPopup, DialogClose }
 
// Composites — assembled, styled, ready to use
export { DialogContent, DialogElementOutsideContent }

Need a new layout? Add it to DialogContent's layout prop — a cva variant, typed, documented in the same file. Need something DialogContent can't do? Reach for the primitives and compose from scratch.

No copies. No drift. One place to look.

src/components/ui/dialog.tsx
const popupVariants = cva("bg-background rounded-md shadow-md overlay-outline", {
  variants: {
    layout: {
      center:    "grid w-full max-w-lg animate-fade-zoom",
      responsive: "w-full rounded-t-[1.25rem] rounded-b-none animate-fade",
      top:       "grid w-full max-w-lg animate-fade-down",
      scrollable: "flex flex-col overflow-hidden animate-fade-zoom",
      stacked:   "fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 animate-fade",
    },
  },
})

Six behaviors. One prop. That's what I mean by a system.

Positions
Behaviors

Code above available at DialogVariants

I didn't design this in the abstract. I was building demos — complex ones, the kind that stress-test the library in ways simple examples don't. Some of them made the architecture decisions for me.

The morphing dialog is one. 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. The element with layoutId must exist in the DOM at both states of the animation.

If DialogPopup is buried inside DialogContent, there's no seam to reach. The only solution is to expose DialogPopup as a primitive that accepts a render prop — and once you do that, DialogContent becomes one valid assembly, not the only one.

tsx
<DialogPopup
  render={<motion.div layoutId={`card-container-${activeItem.id}`} />}
>
  {/* content */}
</DialogPopup>

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

Styles drift too

Structure is only half the problem. If every developer (or AI agent) assembles components differently, styles drift even when the architecture doesn't.

One dev writes transition-all duration-150 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95. Another writes slightly different timing. Popups start feeling inconsistent across the app and nobody notices until it's everywhere.

So I made the styles a contract too.

src/styles/utilities.css
@utility animate-popup {
  @apply origin-(--transform-origin) transition-[opacity,scale,transform] ease-[cubic-bezier(0.23,1,0.32,1)] duration-150;
  &[data-starting-style], &[data-ending-style] {
    opacity: 0;
    scale: 0.95;
  }
}
 
@utility highlight-on-active {
  @apply data-highlighted:relative data-highlighted:z-0
    data-highlighted:before:absolute data-highlighted:before:inset-x-1
    data-highlighted:before:inset-y-0 data-highlighted:before:z-[-1]
    data-highlighted:before:rounded-md data-highlighted:before:bg-accent
    data-highlighted:text-accent-foreground;
}

When anyone needs to animate a popup, they write animate-popup. Not timing curves, not easing functions. The implementation lives once, and every popup in the app feels identical.

There's a reason these use CSS Transitions instead of CSS Animations. Transitions can reverse mid-way. Open a dropdown and immediately close it — it reverses cleanly instead of snapping through the open animation first. It's a small thing, but it's exactly the kind of thing users notice without knowing why.

highlight-on-active is the same idea applied to list items. The ::before pseudo-element renders the visual highlight inset from the edges, while the actual interactive area stays full-width. The row is always clickable edge-to-edge, but the highlight looks floating and rounded. No dead zones. Click Dropdown Menu item below to see it in action.

Learn more at Hit-Test & Highlights

Where AI enters

I was already using AI agents heavily when I built this. And I noticed something: agents produce inconsistent output when the codebase has inconsistent patterns.

If dialogs sometimes use animate-in/animate-out, sometimes use transitions, and sometimes have no animation at all — the agent picks one arbitrarily.

Lumi's architecture removes that ambiguity on purpose:

  • Flat named exports (DialogContent, not Dialog.Content) so agents don't have to guess namespace structure
  • Composites as executable documentation — reading the file teaches the correct assembly pattern
  • Global utilities as the only path to shared styles — no alternative that accidentally produces different behavior
  • Agent skill that loads all of this into the agent's context before it touches anything

When an agent adds a new dropdown, it reaches for DropdownMenuContent from @/components/ui/dropdown-menu. It uses animate-popup because every other popup does. It adds highlight-on-active to items because the existing items do. The output looks like it was written by the same hand as everything else.

The agent becomes a consistent contributor instead of a source of drift.

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 also not an all-or-nothing decision. The composites don't depend on the primitives — they talk to Base UI directly. If you're building something small, you can delete every primitive export and nothing breaks. The composites still work exactly the same. The primitives are just there for when you need them, at the cost of one extra scroll in a file you own.

The whole architecture is also designed to be legible to AI agents — with explicit rules they can learn and follow.

It's a specific set of bets. If they match how you think about building, it might be useful.

What's next

If you want to see what the system produces in practice — complex components like a morphing dialog, a status picker, a multi-tabbed model selector, and a full dashboard template — those are all at lumiui.dev.