Engineering Deep Dive • 10 min read • 06/15/2026

Designing an Internal Component Library with Shadcn/UI and Radix Primitives

Bài này có bản tiếng Việt. Đọc bản tiếng Việt →

Nguyen Hai Trinh

Nguyen Hai Trinh • Senior Software Engineer @ SmileViet

Parts of a user interface system
Photo: heb@Wikimedia Commons (mail) · CC BY-SA 4.0

There’s one decision few people discuss early on that ends up shaping things the longest: what the internal component library is built on.

We have two apps, an admin dashboard and a storefront, sharing one library built on Radix primitives and Shadcn/UI. Here’s how I organized it, along with a few places where we tripped.

Building Dialog from scratch: the temptation and the price

When you need a Dialog or a Select, the biggest temptation is to just write it yourself. It looks simple. A div, a bit of show/hide state, done.

Accessibility is the hard part of these components:

  • A proper focus trap (focus doesn’t “escape” the dialog while it’s open, and returns to the right place on close).
  • Full keyboard navigation (Escape to close, Tab/Shift+Tab cycling inside the dialog, arrow keys for menus and selects).
  • Correct ARIA attributes (role, aria-expanded, aria-controls…) so screen readers understand the state.
  • Correct handling when the component unmounts mid-way, outside clicks, nested dialogs…

Radix Primitives does all of that. It’s unstyled: it hands you behavior and accessibility and leaves the look to you. I don’t have to swallow a whole ready-made design system. I only take the behavior that’s hard to get right.

Shadcn/UI: a CLI that generates code into your repo, not a package

Shadcn/UI isn’t a package you install from npm and import. It’s a CLI that generates code straight into your repo, like components/ui/dialog.tsx. The component library becomes code you own. Change whatever you want, with no lock-in to a third-party library’s API.

// components/ui/dialog.tsx (trimmed)
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { cn } from "@/lib/utils";

export function Dialog({ children, ...props }: DialogPrimitive.DialogProps) {
  return <DialogPrimitive.Root {...props}>{children}</DialogPrimitive.Root>;
}

export function DialogContent({
  className,
  children,
  ...props
}: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>) {
  return (
    <DialogPrimitive.Portal>
      <DialogPrimitive.Overlay className="fixed inset-0 bg-black/50 animate-in fade-in" />
      <DialogPrimitive.Content
        className={cn(
          "fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2",
          "rounded-xl border bg-background p-6 shadow-lg",
          className,
        )}
        {...props}
      >
        {children}
      </DialogPrimitive.Content>
    </DialogPrimitive.Portal>
  );
}

Focus trap, Escape-to-close, portal rendering: all of it comes from @radix-ui/react-dialog. All I add is the Tailwind classes to match the theme.

Where the library lives when several apps share it

Once you have two or more apps, the admin dashboard and the storefront, two questions come up. Where does the library live, and how do you handle different themes?

  • A separate package in the monorepo (packages/design-system) instead of copy-pasting between apps. A bug fix or an accessibility improvement only has to be made in one place.
  • Theming through CSS variables instead of hard-coding colors in components: each app overrides --primary, --radius and so on at its own root, and the components inside don’t need to know which app is using them.
/* apps/admin/globals.css */
:root {
  --primary: 221 83% 53%;
  --radius: 0.5rem;
}

/* apps/storefront/globals.css */
:root {
  --primary: 160 84% 39%;
  --radius: 1rem;
}

Components use hsl(var(--primary)) instead of a fixed value. The same <Button> shows a different color and corner radius in each app, without forking the component.

Stacked into layers, it looks like this:

flowchart TB
  P["Radix Primitives<br/>unstyled: behavior + accessibility<br/>focus trap, Escape, ARIA"]
  P --> S["Shadcn/UI CLI generates code<br/>components/ui/dialog.tsx into the repo"]
  S --> DS["packages/design-system<br/>shared package in the monorepo"]
  DS --> AD["apps/admin<br/>overrides --primary, --radius at the root"]
  DS --> SF["apps/storefront<br/>overrides --primary, --radius at the root"]
  AD --> BT["The same Button<br/>reads hsl(var(--primary))"]
  SF --> BT
  AD -.-> DM[":root and .dark<br/>declare paired variables from day one"]
  SF -.-> DM

Dark mode isn’t something you add later

I’ve been burned by this once. If you design theming without dark mode in mind, adding it later means touching nearly every component you’ve written. Ever since, we define the light/dark variable pairs in parallel from day one, even when the app doesn’t need it yet:

:root {
  --background: 0 0% 100%;
  --foreground: 240 10% 4%;
}

.dark {
  --background: 240 10% 4%;
  --foreground: 0 0% 98%;
}

Versioning when apps don’t upgrade at the same time

A real situation: app A wants the latest version of Button, and app B isn’t ready to migrate a breaking change. Versioning each component individually is too exhausting to maintain. What we chose:

  • Keep breaking changes to a minimum, and prefer adding a new optional prop over changing an existing API.
  • When a breaking change is unavoidable, release a new major version of the whole package, with a changelog that spells out the migration path, so each app decides for itself when to upgrade.

What we got after standardizing

The time to build a new screen dropped sharply. Dialog, select, combobox and data table already exist, are accessibility-tested thoroughly, and just need composing.

The two apps now speak the same design language. Nobody has to remember unwritten rules anymore. The rules live in code, not in one person’s head.

Need a developer for a contract or freelance project?

I take on fullstack projects — Go backends, React/Next.js frontends, or both ends. Send a few lines about the scope and timeline and I'll reply within 48 hours.