DEV Community

Philip Daniel Oghenetega
Philip Daniel Oghenetega

Posted on

Why We Chose Radix UI + Tailwind Over a Full Component Library for a POS App

Enterprise admin tools have a reputation problem: they tend to look like whatever component library they were built with. You can spot a stock MUI dashboard or a default Ant Design table from across the room. For most internal tools, that's a fair trade — you get sorting, theming, and forms for free, and nobody minds that it looks like every other admin panel.

We didn't have that luxury. Our product is a retail POS platform used by merchants and users who interact with our brand daily, across a dozen-plus surfaces — dashboards, tables, forms, modals, mobile card views. It needed to look like us, not like a component library's default theme with our logo pasted on top. So we built on Radix UI primitives and Tailwind CSS instead of adopting a full component library, and after growing that decision across dozens of screens, it's held up.

Here's the actual trade-off, based on how it played out.

What "headless + utility-first" means in practice

Radix UI ships unstyled, accessible primitives — dialog, dropdown menu, select, popover, tabs, toast, tooltip, and more. It handles the hard, easy-to-get-wrong parts (focus trapping in a modal, keyboard navigation in a dropdown, ARIA attributes, portal rendering) and hands you zero opinions about how anything should look.

That means every primitive needs a wrapper before it's usable in product code. We have roughly 22 Radix-backed components living in a src/components/ui/ folder — button, dialog, select, dropdown-menu, popover, tooltip, switch, tabs, and so on — each one a thin, styled layer over the matching Radix primitive. On top of those, product code builds feature-specific components — a Modal wrapper around Dialog that adds our standard header/footer layout, for instance.

Three layers: unstyled behavior (Radix) → styled primitive (our ui/ folder) → product component (feature code). Nobody outside that middle layer touches Radix directly.

// src/components/ui/button.tsx — Radix Slot + cva variants
const buttonVariants = cva(
  "inline-flex items-center justify-center rounded-md text-sm font-medium ...",
  {
    variants: {
      variant: { default: "...", destructive: "...", outline: "...", ghost: "..." },
      size: { default: "h-10 px-4 py-2", sm: "h-9 px-3", lg: "h-11 px-8", icon: "h-10 w-10" },
    },
    defaultVariants: { variant: "default", size: "default" },
  },
);
Enter fullscreen mode Exit fullscreen mode

This is the shadcn/ui pattern — class-variance-authority (cva) for variant management, a cn() helper combining clsx and tailwind-merge for conditional class composition. We never ran the shadcn CLI scaffold or committed a components.json; the pattern was adopted by convention and grew organically, one primitive at a time, as screens needed them. You can see that incrementalism in the commit history itself — commits like "switch added" and "tabs added" rather than one big design-system PR.

Branding lives in the theme layer, not fought against a library's defaults

The entire point of this approach is that visual identity is a config file, not a battle. tailwind.config.ts extends Tailwind's default theme with our brand color scale, semantic design tokens (background, foreground, card, muted, accent, and sidebar-specific variants) expressed as CSS variables so dark mode is a variable swap rather than a duplicated theme object, our brand typeface, and custom keyframe animations for open/close transitions on Radix's dialogs and dropdowns.

// tailwind.config.ts (shape, not actual values)
extend: {
  colors: {
    primary: {
      DEFAULT: "hsl(var(--primary))",
      hover: "hsl(var(--primary-hover))",
    },
    background: "hsl(var(--background))",
    sidebar: { DEFAULT: "hsl(var(--sidebar-bg))" },
  },
  keyframes: {
    "slide-down-fade": { /* Radix data-state driven */ },
  },
},
Enter fullscreen mode Exit fullscreen mode

Because Radix components expose state as data attributes (data-state="open", data-state="closed"), the animation layer can hook directly into them via Tailwind's data-[state=...] variants — no JS-driven animation library needed, no fighting a component library's own transition system.

Accessibility came largely for free — which almost never gets mentioned in "why headless" pitches

The usual pitch for headless UI is composability and bundle size. In practice, the accessibility story mattered just as much and required less deliberate effort than expected. Radix bakes in focus management, aria-selected/aria-expanded state, roving tabindex for menus, and portal-based layering for dialogs and popovers.

Our wrapper components only had to add a handful of sr-only labels for icon-only buttons and explicit role/aria-label attributes on custom-built pieces like pagination controls. Nobody on the team sat down and implemented a focus trap; it came with the primitive.

The honest cost: composability requires discipline you don't get automatically

This is the part libraries like MUI or Ant Design solve by fiat — there's one <Table>, one <Modal>, one toast system, and everyone uses it because there's no alternative bundled in. Headless + utility-first doesn't enforce that for you.

Case in point: we have a fully custom Radix-based toast system (Toast/Toaster/useToast) and react-toastify as a direct dependency, both still in use. That's not a design decision — it's the residue of incremental adoption, where a screen was built against whichever pattern was fastest to reach for at the time, and nobody has gone back to unify them. A component library would never have let that happen, because it would only have shipped one toast API in the first place.

That's the real trade-off, stated plainly: a component library buys you consistency as a default. Headless primitives buy you flexibility as a default, and consistency becomes something the team has to actively maintain — through conventions, code review, and periodically going back to consolidate drift like the toast situation above. We think that trade was correct for a product where the UI is the brand. It would be a worse trade for an internal tool where nobody cares what the dropdown looks like, as long as it works.

Where this leaves us

For a branded, complex, enterprise product, the calculus came down to this: a full component library optimizes for speed-to-first-screen and gives you consistency you didn't have to design. Radix + Tailwind optimizes for the fiftieth screen looking as intentional as the first, at the cost of building and maintaining that consistency yourselves.

We picked the second because the product's UI needed to be a differentiator, not a commodity — and twenty-some wrapped primitives later, we'd make the same call again.

We'd just go back and merge those two toast systems first.


Have you gone headless on a production app? Did the consistency drift bite you too, or did your team find a way to enforce conventions early? Curious what's worked for others.

Top comments (0)