Bỏ qua để vào nội dung chính
Building a Design System with Tailwind CSS + Radix UI in a Micro-Frontend Architecture

Building a Design System with Tailwind CSS + Radix UI in a Micro-Frontend Architecture

Bởi Olivia Reed
03 thg 4, 202612 phút đọc

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 content array 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 use bg-primary and 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-6px-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 asChild for 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.

Không spam, hủy đăng ký bất kỳ lúc nào.

Bài viết liên quan