TL;DR: Learn how to architect a scalable, accessible enterprise design system by combining Radix UI's headless primitives with Tailwind CSS. This guide covers creating a robust
cnutility usingclsxandtailwind-mergeto resolve class conflicts, mapping semantic CSS variables for dynamic theming, and building polymorphic components with Radix'sSlot.
⚡ Key Takeaways
- Use Radix UI's unstyled primitives to handle complex WAI-ARIA compliance, focus trapping, and portal rendering without fighting opinionated component libraries.
- Implement a custom
cnutility combiningclsxandtailwind-mergeto intelligently override default Tailwind classes and prevent CSS cascade conflicts. - Define semantic design tokens using raw HSL values (e.g.,
--primary: 221.2 83.2% 53.3%;) in your global CSS to allow Tailwind to seamlessly inject opacity modifiers. - Map CSS variables to your
tailwind.config.jstheme to create a scalable, easily updatable foundation for light and dark modes. - Utilize Radix UI's
Slotprimitive to build polymorphic components, allowing a standard<Button>to safely render as an<a>tag or Next.js<Link>without duplicating styles.
You have been tasked with standardizing the UI across your company's suite of React applications. At first, your team might attempt to build custom components from scratch using standard React state and CSS. Within weeks, you are drowning in edge cases: the custom select dropdown gets cut off by overflow: hidden containers, your modals fail to trap focus for screen readers, and keyboard navigation on your tabs is fundamentally broken.
Frustrated, you consider migrating to a massive component library like Material UI or Ant Design. But as soon as the design team hands over a highly bespoke, brand-specific Figma file, you realize those libraries will fight you at every turn. You will spend more time overriding deeply nested CSS classes than writing actual product features.
This is the exact breaking point where headless UI components save the day. By decoupling accessibility and behavior from visual styling, we can build a scalable, enterprise-grade design system. In this guide, we will architect a component library using Radix UI for unstyled, accessible logic and Tailwind CSS for heavily constrained, design-token-driven styling.
The Architectural Foundation: Why This Stack?
When building a design system, developers often underestimate the sheer volume of logic required to comply with WAI-ARIA standards. A seemingly simple <Select> component requires handling click-outside events, portal rendering, typeahead search, and arrow-key navigation.
Radix UI solves this by providing unstyled React components that manage all complex DOM interactions. Tailwind CSS complements this by allowing us to colocate our styles and enforce design tokens (colors, spacing, typography) without writing sprawling CSS modules.
Let's install the dependencies required to build our foundation:
npm install @radix-ui/react-slot @radix-ui/react-dialog @radix-ui/react-select
npm install tailwindcss class-variance-authority clsx tailwind-merge tailwindcss-animate lucide-react
The Ultimate Utility: The cn Function
Before building any components, we need a reliable way to dynamically merge Tailwind classes. If a developer passes a custom className to our button, it should intelligently override our default styles.
Create a lib/utils.ts file:
// lib/utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
Why this matters:
clsxhandles conditional class logic (e.g.,{'opacity-50': disabled}), whiletailwind-mergeunderstands Tailwind's utility hierarchy. WithouttwMerge, if your default button haspx-4and a consumer passespx-2, the browser's CSS cascading rules might arbitrarily applypx-4anyway.
Configuring Tailwind for Enterprise Design Tokens
Hardcoding colors like bg-blue-500 throughout your components is a recipe for technical debt. When the brand changes, you don't want to run a massive find-and-replace operation. Instead, we use CSS variables mapped to semantic Tailwind tokens. When we provide full-stack web development services for global clients, semantic tokenization is a non-negotiable step before writing a single React component.
Define your variables in your global CSS. Notice how we define the HSL values without the hsl() wrapper—this allows Tailwind to inject opacity modifiers seamlessly:
/* styles/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--ring: 217.2 91.2% 59.8%;
}
}
Now, map these tokens and the animation plugin in your tailwind.config.js:
// tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./src/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
ring: "hsl(var(--ring))",
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
},
},
plugins: [require("tailwindcss-animate")],
};
Building a Scalable, Polymorphic Button
A button is never just a button. Sometimes it needs to act as a link (<a>), or it needs to integrate seamlessly with Next.js's <Link> component. Radix provides a primitive called Slot that enables polymorphic components—components that can render as different HTML elements while retaining their styled behavior.
We will use class-variance-authority (cva) to define our styling variants cleanly.
// components/ui/button.tsx
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive: "bg-red-500 text-white shadow-sm hover:bg-red-500/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };
Production Note: The
asChildprop is incredibly powerful. WhenasChildis true, Radix'sSlotmerges the button's props and classes onto its immediate child element. This means<Button asChild><a href="/login">Login</a></Button>renders a perfectly styled, accessible<a>tag without creating nested, invalid HTML.
Taming Complexity: The Accessible Dialog (Modal)
Building a custom modal usually involves a z-index arms race and managing complex overlay state. Furthermore, screen reader users often find themselves navigating the background page even when a modal is open. Radix's Dialog primitive handles focus trapping, scroll locking, and ARIA announcements automatically.
By combining Radix with Tailwind, we can abstract this complex primitive into a highly reusable, beautifully styled component.
// components/ui/dialog.tsx
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
export { Dialog, DialogTrigger, DialogContent };
Notice the heavy use of data-[state=open] attributes in the Tailwind classes. Radix exposes these data attributes automatically based on the internal state machine of the component. We leverage these attributes alongside the tailwindcss-animate plugin to orchestrate buttery-smooth entrance and exit animations without writing custom CSS transitions or importing bloated animation libraries.
Composing the Select Dropdown: Advanced State Management
The <select> HTML element is notoriously difficult to style uniformly across different browsers. Most teams end up building custom dropdowns, only to discover that replicating native browser behavior (like pressing 'C' to jump to Canada in a country list) takes weeks of engineering.
Radix's Select component gives us the power to completely redesign the visual appearance while retaining the robust accessibility of native selects.
// components/ui/select.tsx
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-background text-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent };
Warning: Pay close attention to
SelectPrimitive.Portal. Portals render the dropdown menu outside the immediate DOM hierarchy (usually appending it directly to the<body>). This prevents the dropdown from being clipped byoverflow: hiddenparent containers—a bug that plagues almost all homegrown UI systems.
Exposing the Library: Monorepos and Exports
Once you have built out your core primitives (Buttons, Inputs, Cards, Dialogs, Selects), the next step in enterprise architecture is distribution. Rather than copying and pasting these files across micro-frontends, standard practice is to wrap this in a Turborepo or Nx monorepo as an internal package (e.g., @acme/ui).
To distribute this package cleanly, configure your package.json exports:
{
"name": "@acme/ui",
"version": "1.0.0",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": "./dist/index.js",
"./button": "./dist/components/button.js",
"./dialog": "./dist/components/dialog.js",
"./select": "./dist/components/select.js",
"./styles.css": "./dist/styles.css"
},
"peerDependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"tailwindcss": "^3.3.0"
}
}
This configuration allows consuming applications to import only what they need (import { Button } from '@acme/ui/button'), optimizing bundle sizes and ensuring a consistent developer experience across the organization.
The combination of Radix UI and Tailwind CSS represents the sweet spot of modern frontend architecture. It provides your team with the velocity of a pre-built component library while retaining the absolute control of a fully bespoke design system. You avoid fighting pre-packaged styling frameworks, ensure rigorous accessibility standards from day one, and set a stable foundation for your product to scale.
Need help building this in production?
SoftwareCrafting is a full-stack dev agency — we ship fast, scalable React, Next.js, Node.js, React Native & Flutter apps for global clients.
Get a Free ConsultationFrequently Asked Questions
Why should I use Radix UI instead of a pre-built component library like Material UI?
Pre-built libraries like Material UI are notoriously difficult to customize when dealing with highly bespoke, brand-specific designs. Radix UI provides headless, unstyled components that handle complex accessibility and behavior logic, allowing you to fully control the visual styling from scratch using Tailwind CSS.
Why is the cn utility function necessary when using Tailwind CSS in React?
The cn function combines clsx for conditional classes and tailwind-merge to intelligently resolve Tailwind utility conflicts. Without it, passing a custom class like px-2 to a component with a default px-4 class might fail to apply properly due to standard browser CSS cascading rules.
How do you manage brand colors and design tokens in a Tailwind CSS enterprise design system?
Instead of hardcoding utility classes, you should define CSS variables in your global stylesheet and map them to semantic Tailwind tokens in your tailwind.config.js. When we provide full-stack web development services at SoftwareCrafting, implementing this semantic tokenization is a non-negotiable step to ensure scalable, themeable architectures.
Why define CSS variables with raw HSL values instead of using the hsl() wrapper?
Defining raw HSL values (e.g., 222.2 84% 4.9%) in your CSS variables allows Tailwind CSS to seamlessly inject opacity modifiers. If you include the hsl() wrapper in the variable itself, Tailwind's dynamic opacity utilities like bg-primary/50 will fail to render correctly.
What are the accessibility benefits of using headless UI components for enterprise applications?
Headless UI components automatically handle complex WAI-ARIA standards, such as focus trapping, arrow-key navigation, and click-outside events for elements like selects and modals. At SoftwareCrafting, our frontend architecture services leverage these unstyled primitives to guarantee that custom-designed interfaces remain fully accessible without the need to write complex manual event listeners.
How can I make my React button component polymorphic to work with Next.js links?
You can use the Slot primitive from Radix UI to create polymorphic components. This allows your button component to merge its props and render as a different HTML tag or a Next.js <Link> while seamlessly retaining all of its default Tailwind styling.
📎 Full Code on GitHub Gist: The complete
commands-1.shfrom this post is available as a standalone GitHub Gist — copy, fork, or embed it directly.
