Building a Design System with Tailwind CSS + Radix UI in a Micro-Frontend Architecture
A practical deep dive into building a scalable, themeable design system using Tailwind CSS and Radix UI primitives — shared across micro-frontends via npm packages and Module Federation. Covers design tokens, shared Tailwind config, headless component layers, API design, versioning, and multi-theme support.
Why Design Systems Break in Micro-Frontend Architectures
Micro-frontend (MFE) architecture solves the problem of scaling large frontend teams by splitting a monolithic UI into independently deployable pieces. But it creates a new, thorny challenge: visual consistency. When five teams each own their own React app, they inevitably drift — different button styles, inconsistent spacing, conflicting font stacks. The design system becomes the connective tissue that holds the user experience together.
The combination of Tailwind CSS and Radix UI is a particularly powerful foundation for this. Tailwind gives you a constraint-based utility system that can be configured centrally. Radix gives you fully accessible, unstyled primitives that you can style however you want. Together, they let you ship a design system that is headless by nature, themeable by default, and shareable across teams without imposing a heavy runtime.
This article goes deep on how to architect that system — from design token strategy to Module Federation, from component API design to versioning discipline.
Design Token Strategy: The Foundation of Everything
Design tokens are the single source of truth for your visual language. Before writing a single component, define your tokens. These are not just CSS variables — they are semantic decisions baked into a structure that Tailwind can consume.
A well-structured token hierarchy has three levels:
- Primitive tokens — raw values:
blue-500,16px,400 - Semantic tokens — intent-based aliases:
color-primary,spacing-md,font-weight-body - Component tokens — scoped overrides:
button-bg,dialog-radius
Store your tokens in a single JSON or TypeScript file that lives in your shared design package:
// packages/design-tokens/src/tokens.ts
export const tokens = {
color: {
primary: {
DEFAULT: '#6366f1', // indigo-500
hover: '#4f46e5', // indigo-600
foreground: '#ffffff',
},
destructive: {
DEFAULT: '#ef4444',
foreground: '#ffffff',
},
surface: {
DEFAULT: '#ffffff',
muted: '#f9fafb',
border: '#e5e7eb',
},
text: {
DEFAULT: '#111827',
muted: '#6b7280',
inverted: '#ffffff',
},
},
radius: {
sm: '0.25rem',
md: '0.5rem',
lg: '0.75rem',
full: '9999px',
},
spacing: {
xs: '0.5rem',
sm: '0.75rem',
md: '1rem',
lg: '1.5rem',
xl: '2rem',
},
} as const;
Why TypeScript? Because downstream packages get autocomplete and type safety when referencing tokens. This single file becomes the authoritative source — no more "what shade of blue is the primary button?" debates.
Tailwind Config as a Shared Package
One of the most underused patterns in the Tailwind ecosystem is extracting your tailwind.config into a versioned npm package. Instead of each MFE copy-pasting a config, they all extend from a shared preset.
// packages/tailwind-config/index.ts
import type { Config } from 'tailwindcss';
import { tokens } from '@acme/design-tokens';
const config: Config = {
content: [],
theme: {
extend: {
colors: {
primary: tokens.color.primary,
destructive: tokens.color.destructive,
surface: tokens.color.surface,
text: tokens.color.text,
},
borderRadius: tokens.radius,
spacing: tokens.spacing,
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
},
},
},
plugins: [],
};
export default config;
Each micro-frontend then simply extends this preset in its own tailwind.config.ts:
// apps/checkout-mfe/tailwind.config.ts
import baseConfig from '@acme/tailwind-config';
import type { Config } from 'tailwindcss';
const config: Config = {
...baseConfig,
content: [
'./src/**/*.{ts,tsx}',
'../../packages/ui/src/**/*.{ts,tsx}',
],
};
export default config;
Critical: The
contentarray must include the paths to your shared UI package. Tailwind's JIT compiler only generates classes it finds in scanned files — if your shared components usebg-primaryand the MFE doesn't scan those files, the class gets purged in production.
This pattern keeps all MFEs visually synchronized. When the design team updates a color token, they bump the @acme/tailwind-config version. Teams adopt the update on their own schedule but within a controlled upgrade path.
Radix UI as the Headless Primitive Layer
Radix UI provides battle-tested, fully accessible component primitives with zero default styling. This is exactly what a design system needs at its core: behavior without opinion. Accessibility — focus management, ARIA attributes, keyboard navigation — is handled for you. You only worry about visual design.
The architecture looks like this:
- Radix primitive — handles behavior, accessibility, state
- Your wrapper component — applies Tailwind classes, exposes a clean API
- Design token — drives the visual output
This is called the headless component pattern, and it's the right model for shared design systems because it separates concerns cleanly.
Building Real Components: Button
Let's build a production-quality Button component. We use class-variance-authority (CVA) to manage variant-based class composition, which pairs perfectly with Tailwind.
// packages/ui/src/components/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 '../utils/cn';
const buttonVariants = cva(
// Base styles — always applied
'inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium' +
' transition-colors focus-visible:outline-none focus-visible:ring-2' +
' focus-visible:ring-primary focus-visible:ring-offset-2' +
' disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-text-inverted hover:bg-primary-hover',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-surface-border bg-surface hover:bg-surface-muted',
ghost: 'hover:bg-surface-muted hover:text-text',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
sm: 'h-8 px-3 text-xs',
md: 'h-10 px-4',
lg: 'h-12 px-6 text-base',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'md',
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
ref={ref}
className={cn(buttonVariants({ variant, size }), className)}
{...props}
/>
);
}
);
Button.displayName = 'Button';
The asChild prop (from Radix's Slot) is a game-changer. It lets consumers render the button styles on any element — a link, a custom component — without prop-drilling or wrapper divs:
<Button asChild variant="outline">
<a href="/dashboard">Go to Dashboard</a>
</Button>
Building Real Components: Dialog
The Dialog is where Radix really earns its keep. Focus trapping, scroll locking, portal rendering, ARIA roles — all handled. You just style it.
// packages/ui/src/components/Dialog.tsx
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { cn } from '../utils/cn';
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
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-black/50 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-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2',
'w-full max-w-lg rounded-lg bg-surface p-6 shadow-xl',
'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',
className
)}
{...props}
>
{children}
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col gap-1.5 text-left', className)} {...props} />
);
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold text-text', className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
export { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogClose };
Usage in any MFE is clean and declarative:
<Dialog>
<DialogTrigger asChild>
<Button>Open Settings</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Account Settings</DialogTitle>
</DialogHeader>
<p className="text-text-muted text-sm">Manage your preferences here.</p>
</DialogContent>
</Dialog>
MFE Sharing Strategies
There are two main approaches for sharing your design system across micro-frontends, each with distinct trade-offs.
Strategy 1: Versioned npm Package
Publish your UI package to a private npm registry (GitHub Packages, Verdaccio, npm Enterprise). Each MFE declares a dependency like "@acme/ui": "^2.3.0".
Pros:
- Strong version boundaries — MFEs can upgrade independently
- Works with any bundler (Vite, webpack, esbuild)
- Tree-shakeable — only imported components are bundled
- Clear audit trail via changelog and semver
Cons:
- Each MFE bundles its own copy of the components → larger total JS payload
- React must be deduplicated carefully to avoid hook violations
- Upgrade adoption can be slow across many teams
For deduplication, ensure all MFEs declare React as a peer dependency and use the host shell's React instance:
// packages/ui/package.json
{
"peerDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
}
Strategy 2: Module Federation
With Webpack 5's Module Federation (or Vite's @originjs/vite-plugin-federation), you expose your design system as a remote module loaded at runtime.
// shell/webpack.config.js (host)
new ModuleFederationPlugin({
name: 'shell',
remotes: {
designSystem: 'designSystem@https://cdn.acme.com/design-system/remoteEntry.js',
},
shared: {
react: { singleton: true, requiredVersion: '^18' },
'react-dom': { singleton: true, requiredVersion: '^18' },
},
});
// packages/ui/webpack.config.js (remote)
new ModuleFederationPlugin({
name: 'designSystem',
filename: 'remoteEntry.js',
exposes: {
'./Button': './src/components/Button',
'./Dialog': './src/components/Dialog',
},
shared: {
react: { singleton: true },
'react-dom': { singleton: true },
},
});
Consuming MFEs import components as if they were local:
const { Button } = await import('designSystem/Button');
Pros:
- Single copy of the design system loaded once — smaller total bundle
- Deploy a design system update without redeploying MFEs
- React singleton guaranteed — no hook violation risk
Cons:
- Runtime coupling — a design system deploy can break MFEs if not managed carefully
- Requires careful cache-busting strategy on CDN
- Adds build complexity and webpack lock-in (though Vite alternatives exist)
For most teams starting out, the npm package approach is safer and simpler. Reach for Module Federation when you have 5+ MFEs and the duplicated bundle cost becomes measurable.
Versioning Discipline
A shared design system that breaks consumers without warning is worse than no design system at all. Adopt strict semantic versioning and enforce it:
- Patch (1.0.x) — bug fixes, style tweaks that don't change component APIs
- Minor (1.x.0) — new components, new variants, additive prop changes
- Major (x.0.0) — breaking API changes, token renames, removed components
Use changesets to automate this discipline:
# When you make a change
npx changeset
# In CI/CD — bumps versions and publishes
npx changeset version
npx changeset publish
Maintain a CHANGELOG.md per package. This gives consuming teams a migration path, not a surprise. For Module Federation deployments, use immutable URLs with content hashes (remoteEntry.abc123.js) so you can deploy new versions alongside old ones during transitions.
Theming: Dark Mode and Multi-Brand Support
Theming in this architecture is handled at the CSS custom properties layer. Tailwind's darkMode: 'class' strategy pairs perfectly with Radix's data attributes.
Define your themes as CSS variable overrides scoped to a class or data attribute:
/* packages/ui/src/styles/themes.css */
:root {
--color-primary: 99 102 241; /* indigo-500 */
--color-primary-hover: 79 70 229; /* indigo-600 */
--color-surface: 255 255 255;
--color-text: 17 24 39;
}
.dark {
--color-primary: 129 140 248; /* indigo-400 — lighter for dark bg */
--color-primary-hover: 99 102 241;
--color-surface: 17 24 39;
--color-text: 243 244 246;
}
[data-brand="teal"] {
--color-primary: 20 184 166; /* teal-500 */
--color-primary-hover: 13 148 136;
}
Update your Tailwind config to reference these CSS variables using Tailwind's RGB channel trick for opacity support:
// packages/tailwind-config/index.ts
colors: {
primary: {
DEFAULT: 'rgb(var(--color-primary) / <alpha-value>)',
hover: 'rgb(var(--color-primary-hover) / <alpha-value>)',
},
surface: 'rgb(var(--color-surface) / <alpha-value>)',
text: 'rgb(var(--color-text) / <alpha-value>)',
},
Now bg-primary/50 (50% opacity primary) works out of the box, and switching themes is as simple as toggling a class on <html> or any ancestor element. In a multi-brand MFE setup, each shell application can set its brand data attribute and the entire nested component tree responds automatically — no prop drilling, no context providers required.
Putting It All Together: The Monorepo Structure
The complete package structure for this architecture looks like this:
acme-design-system/
├── packages/
│ ├── design-tokens/ # Token definitions (TypeScript)
│ │ └── src/tokens.ts
│ ├── tailwind-config/ # Shared Tailwind preset
│ │ └── index.ts
│ └── ui/ # Component library
│ ├── src/
│ │ ├── components/
│ │ │ ├── Button.tsx
│ │ │ ├── Dialog.tsx
│ │ │ └── index.ts
│ │ ├── styles/
│ │ │ └── themes.css
│ │ └── utils/
│ │ └── cn.ts # clsx + tailwind-merge
│ └── package.json
└── apps/
├── storybook/ # Documentation & visual testing
├── checkout-mfe/
└── dashboard-mfe/
The cn utility deserves a mention — it's the glue that makes Tailwind composable:
// packages/ui/src/utils/cn.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
tailwind-merge resolves conflicting Tailwind classes (e.g., px-4 px-6 → px-6), which is critical when consumers pass className overrides to your components.
Conclusion
Building a design system for micro-frontends is an infrastructure problem as much as a design problem. The combination of Tailwind CSS and Radix UI gives you a uniquely well-suited foundation: tokens-first visual language, headless accessibility-ready primitives, and zero-runtime CSS utility generation.
The key architectural decisions are:
- Define tokens first, in TypeScript, as the single source of truth
- Extract the Tailwind config into a versioned shared package
- Use Radix primitives as the behavior layer, style them with CVA + Tailwind
- Expose a clean, forwardRef-based API with
asChildfor maximum flexibility - Choose npm packages for simplicity, Module Federation for scale
- Version strictly with changesets, communicate changes via changelog
- Drive theming through CSS custom properties scoped to classes or data attributes
Teams that invest in this foundation move faster, not slower. The upfront cost of a well-architected shared system pays back in every sprint where a developer doesn't have to reinvent a button.
Admin
Cal.com
Open source scheduling — self-host your booking system, replace Calendly. Free & privacy-first.
Comments (0)
Sign in to comment
No comments yet. Be the first to comment!